@onlineapps/conn-orch-validator 3.3.1 → 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 +389 -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 +252 -45
- 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
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* MockMQClient - Simulates RabbitMQ for testing
|
|
5
5
|
* Provides in-memory message queue simulation
|
|
6
|
+
*
|
|
7
|
+
* The surface mirrors `@onlineapps/mq-client-core` `BaseClient` — that is the
|
|
8
|
+
* client every service actually holds, so a divergence here does not merely make
|
|
9
|
+
* a test unrealistic: it teaches whoever codes against this mock to write the
|
|
10
|
+
* wrong call. Method names, argument shapes, defaults and error wording are kept
|
|
11
|
+
* in step with `shared/mq-client-core/src/BaseClient.js` and the RabbitMQ
|
|
12
|
+
* transport it delegates to.
|
|
13
|
+
*
|
|
14
|
+
* @see ../../../../mq-client-core/src/BaseClient.js
|
|
15
|
+
* @see ../../../../mq-client-core/src/transports/rabbitmqClient.js
|
|
6
16
|
*/
|
|
7
17
|
class MockMQClient {
|
|
8
18
|
constructor() {
|
|
9
19
|
this.queues = {};
|
|
10
20
|
this.consumers = {};
|
|
11
|
-
this.
|
|
21
|
+
this._connected = false;
|
|
12
22
|
this.publishedMessages = [];
|
|
13
23
|
this.acknowledgedMessages = [];
|
|
14
24
|
this.rejectedMessages = [];
|
|
@@ -18,7 +28,7 @@ class MockMQClient {
|
|
|
18
28
|
* Simulate connection
|
|
19
29
|
*/
|
|
20
30
|
async connect() {
|
|
21
|
-
this.
|
|
31
|
+
this._connected = true;
|
|
22
32
|
return Promise.resolve();
|
|
23
33
|
}
|
|
24
34
|
|
|
@@ -26,17 +36,33 @@ class MockMQClient {
|
|
|
26
36
|
* Simulate disconnection
|
|
27
37
|
*/
|
|
28
38
|
async disconnect() {
|
|
29
|
-
this.
|
|
39
|
+
this._connected = false;
|
|
30
40
|
this.consumers = {};
|
|
31
41
|
return Promise.resolve();
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Connection status.
|
|
46
|
+
*
|
|
47
|
+
* A METHOD, matching `BaseClient.isConnected()`. It used to be a boolean
|
|
48
|
+
* property of the same name, which is the more dangerous kind of mismatch:
|
|
49
|
+
* `if (client.isConnected)` written against that mock reads a function object
|
|
50
|
+
* on the real client — always truthy — so the guard never fires and nothing
|
|
51
|
+
* fails loudly.
|
|
52
|
+
*
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
isConnected() {
|
|
56
|
+
return this._connected === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
/**
|
|
35
60
|
* Publish message to queue
|
|
36
61
|
*/
|
|
37
62
|
async publish(queue, message, options = {}) {
|
|
38
|
-
if (!this.
|
|
39
|
-
|
|
63
|
+
if (!this._connected) {
|
|
64
|
+
// Wording per BaseClient.publish (ConnectionError).
|
|
65
|
+
throw new Error('Cannot publish: client is not connected');
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
if (!this.queues[queue]) {
|
|
@@ -95,8 +121,9 @@ class MockMQClient {
|
|
|
95
121
|
* Consume messages from queue
|
|
96
122
|
*/
|
|
97
123
|
async consume(queue, callback, options = {}) {
|
|
98
|
-
if (!this.
|
|
99
|
-
|
|
124
|
+
if (!this._connected) {
|
|
125
|
+
// Wording per BaseClient.consume (ConnectionError).
|
|
126
|
+
throw new Error('Cannot consume: client is not connected');
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
this.consumers[queue] = {
|
|
@@ -118,36 +145,94 @@ class MockMQClient {
|
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
/**
|
|
121
|
-
* Acknowledge message
|
|
148
|
+
* Acknowledge message.
|
|
149
|
+
*
|
|
150
|
+
* Signature and idempotence per `BaseClient.ack(msg)` → transport
|
|
151
|
+
* `rabbitmqClient.js:2286`: a delivery already settled (marked
|
|
152
|
+
* `_mqProcessed`) is silently skipped rather than settled twice.
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} message - Broker message object
|
|
155
|
+
* @returns {Promise<void>}
|
|
122
156
|
*/
|
|
123
157
|
async ack(message) {
|
|
158
|
+
if (message && message._mqProcessed) {
|
|
159
|
+
return; // Already acked/nacked — idempotent, as in the real transport
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
this.acknowledgedMessages.push({
|
|
125
163
|
message,
|
|
126
164
|
timestamp: Date.now()
|
|
127
165
|
});
|
|
128
|
-
|
|
166
|
+
|
|
167
|
+
if (message) {
|
|
168
|
+
message._mqProcessed = true;
|
|
169
|
+
}
|
|
129
170
|
}
|
|
130
171
|
|
|
131
172
|
/**
|
|
132
|
-
*
|
|
173
|
+
* Negative-acknowledge a message.
|
|
174
|
+
*
|
|
175
|
+
* Signature and semantics per `BaseClient.nack(msg, options)` → transport
|
|
176
|
+
* `rabbitmqClient.js:2313`:
|
|
177
|
+
*
|
|
178
|
+
* const requeue = options.requeue !== undefined ? options.requeue : true;
|
|
179
|
+
*
|
|
180
|
+
* `requeue` therefore defaults to TRUE. This mock previously carried the raw
|
|
181
|
+
* amqplib positional form `nack(message, allUpTo, requeue)` with `requeue`
|
|
182
|
+
* defaulting to FALSE — the exact opposite — so code written against it
|
|
183
|
+
* either dropped messages it meant to retry or retried forever what it meant
|
|
184
|
+
* to drop. Neither `BaseClient` nor the transport exposes `allUpTo`; it is
|
|
185
|
+
* pinned to `false` inside the transport and is not part of the contract.
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} message - Broker message object
|
|
188
|
+
* @param {Object} [options] - { requeue: boolean } — requeue defaults to true
|
|
189
|
+
* @returns {Promise<void>}
|
|
133
190
|
*/
|
|
134
|
-
async nack(message,
|
|
191
|
+
async nack(message, options = {}) {
|
|
192
|
+
if (message && message._mqProcessed) {
|
|
193
|
+
return; // Already acked/nacked — idempotent, as in the real transport
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const requeue = options.requeue !== undefined ? options.requeue : true;
|
|
197
|
+
|
|
198
|
+
if (requeue && !(message && message.fields && message.fields.routingKey)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
'[MockMQClient] Cannot requeue: message has no fields.routingKey - ' +
|
|
201
|
+
'a broker delivery always carries one. Fix: nack a message obtained from ' +
|
|
202
|
+
'consume(), or pass { requeue: false } to discard it.'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
135
206
|
this.rejectedMessages.push({
|
|
136
207
|
message,
|
|
137
|
-
allUpTo,
|
|
138
208
|
requeue,
|
|
139
209
|
timestamp: Date.now()
|
|
140
210
|
});
|
|
141
211
|
|
|
142
|
-
if (
|
|
212
|
+
if (message) {
|
|
213
|
+
message._mqProcessed = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (requeue) {
|
|
143
217
|
const queue = message.fields.routingKey;
|
|
144
218
|
if (!this.queues[queue]) {
|
|
145
219
|
this.queues[queue] = [];
|
|
146
220
|
}
|
|
221
|
+
// A requeued message comes back as a NEW delivery with a new delivery tag,
|
|
222
|
+
// so it is settleable again. Keeping it marked would cap the retry loop at
|
|
223
|
+
// one round and hide the very behaviour this mock has to reproduce.
|
|
224
|
+
message._mqProcessed = false;
|
|
147
225
|
this.queues[queue].push(message);
|
|
148
|
-
}
|
|
149
226
|
|
|
150
|
-
|
|
227
|
+
// The broker hands that redelivery straight to the live consumer. Skipping
|
|
228
|
+
// it is what would hide an always-failing handler's infinite retry loop.
|
|
229
|
+
if (this.consumers[queue]) {
|
|
230
|
+
const consumer = this.consumers[queue];
|
|
231
|
+
setImmediate(() => {
|
|
232
|
+
consumer.callback(message);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
151
236
|
}
|
|
152
237
|
|
|
153
238
|
/**
|
|
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const net = require('net');
|
|
6
6
|
const { execSync } = require('child_process');
|
|
7
|
+
const { readIntegrationRunArtefact } = require('./integrationRun');
|
|
8
|
+
const { normalizeEnvDeclaration, collectEnvCoverage } = require('./envContract');
|
|
7
9
|
|
|
8
10
|
const CONNECTOR_KEYS = ['db', 'redis', 'mq', 'minio'];
|
|
9
11
|
const DEFAULT_CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
|
|
@@ -37,7 +39,78 @@ function assertBoolean(value, fieldName) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// The service declares WHAT its database is; the library implements HOW it is
|
|
43
|
+
// built (see docs/biz/00-model/uniformity-principle.md). Everything the six
|
|
44
|
+
// per-repo ci-setup-db.js scripts used to decide for themselves — client,
|
|
45
|
+
// ordering, foreign-key handling — is a platform property and lives in the
|
|
46
|
+
// library; only these four values legitimately differ per service.
|
|
47
|
+
const SCHEMA_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
const ENGINE_DECLARATION = /^(mariadb|mysql):[0-9][0-9.]*$/;
|
|
49
|
+
|
|
50
|
+
/** A declared path must stay inside the repository — it is resolved against the service root. */
|
|
51
|
+
function assertRepoRelativePath(value, fieldName) {
|
|
52
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
53
|
+
throw new Error(`[BizCiGate] Invalid ${fieldName} - Expected a non-empty path relative to the service root. `
|
|
54
|
+
+ 'Fix: e.g. "migrations/*.sql".');
|
|
55
|
+
}
|
|
56
|
+
if (path.isAbsolute(value) || value.split('/').includes('..')) {
|
|
57
|
+
throw new Error(`[BizCiGate] Invalid ${fieldName} - "${value}" points outside the service root. `
|
|
58
|
+
+ 'Fix: use a path relative to the repository, without ".." or a leading "/".');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeDatabaseDeclaration(database, connectors) {
|
|
63
|
+
// Absent means "this service has no database". Null rather than {} so callers
|
|
64
|
+
// distinguish that from an empty declaration and skip the steps explicitly.
|
|
65
|
+
if (database === undefined || database === null) return null;
|
|
66
|
+
|
|
67
|
+
if (typeof database !== 'object' || Array.isArray(database)) {
|
|
68
|
+
throw new Error('[BizCiGate] Invalid database - Expected an object with engine, schema and migrations. '
|
|
69
|
+
+ 'Fix: omit the key entirely if the service has no database.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!connectors.db) {
|
|
73
|
+
throw new Error('[BizCiGate] Contradictory contract - database is declared but requiredConnectors.db is false. '
|
|
74
|
+
+ 'Fix: set requiredConnectors.db to true, or remove the database declaration.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!ENGINE_DECLARATION.test(database.engine ?? '')) {
|
|
78
|
+
throw new Error(`[BizCiGate] Invalid database.engine - "${database.engine}" is not <mariadb|mysql>:<version>. `
|
|
79
|
+
+ 'Fix: declare the engine AND its version, e.g. "mariadb:10.5" — CI runs exactly this image, '
|
|
80
|
+
+ 'and testing on a different one proves nothing about the service SQL.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!SCHEMA_IDENTIFIER.test(database.schema ?? '')) {
|
|
84
|
+
throw new Error(`[BizCiGate] Invalid database.schema - "${database.schema}" is not a plain identifier. `
|
|
85
|
+
+ 'Fix: use the real schema name, e.g. "oagen_emailer"; it is interpolated into CREATE DATABASE and USE.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
assertRepoRelativePath(database.migrations, 'database.migrations');
|
|
89
|
+
|
|
90
|
+
const seeds = database.seeds ?? [];
|
|
91
|
+
if (!Array.isArray(seeds)) {
|
|
92
|
+
throw new Error('[BizCiGate] Invalid database.seeds - Expected an array of paths. '
|
|
93
|
+
+ 'Fix: use ["scripts/seed/system.seed.sql"], or omit the key.');
|
|
94
|
+
}
|
|
95
|
+
seeds.forEach((seed, i) => assertRepoRelativePath(seed, `database.seeds[${i}]`));
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
engine: database.engine,
|
|
99
|
+
schema: database.schema,
|
|
100
|
+
migrations: database.migrations,
|
|
101
|
+
seeds
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {object} rawContract parsed integration-contract.json
|
|
107
|
+
* @param {string} contractPath for error messages
|
|
108
|
+
* @param {object} [options]
|
|
109
|
+
* @param {Map<string,string>} [options.envCoverage] what M1/M2 already cover; supplied by
|
|
110
|
+
* loadAndValidateIntegrationContract, which is the caller that can read the repository.
|
|
111
|
+
* Mandatory only when the contract carries an env block — see utils/envContract.js.
|
|
112
|
+
*/
|
|
113
|
+
function normalizeIntegrationContract(rawContract, contractPath, options = {}) {
|
|
41
114
|
if (!rawContract || typeof rawContract !== 'object' || Array.isArray(rawContract)) {
|
|
42
115
|
throw new Error(`[BizCiGate] Invalid contract root - Expected object in ${contractPath}`);
|
|
43
116
|
}
|
|
@@ -64,12 +137,17 @@ function normalizeIntegrationContract(rawContract, contractPath) {
|
|
|
64
137
|
throw new Error('[BizCiGate] Invalid integrationMinimum.minTestFiles - Expected integer >= 1');
|
|
65
138
|
}
|
|
66
139
|
|
|
140
|
+
const database = normalizeDatabaseDeclaration(rawContract.database, normalizedConnectors);
|
|
141
|
+
const env = normalizeEnvDeclaration(rawContract.env, { coverage: options.envCoverage });
|
|
142
|
+
|
|
67
143
|
return {
|
|
68
144
|
serviceName: rawContract.serviceName || null,
|
|
69
145
|
requiredConnectors: normalizedConnectors,
|
|
70
146
|
integrationMinimum: {
|
|
71
147
|
minTestFiles,
|
|
72
148
|
},
|
|
149
|
+
database,
|
|
150
|
+
env,
|
|
73
151
|
setup: rawContract.setup || {},
|
|
74
152
|
raw: rawContract,
|
|
75
153
|
};
|
|
@@ -79,7 +157,15 @@ function loadAndValidateIntegrationContract(serviceRoot, contractPath) {
|
|
|
79
157
|
const resolvedServiceRoot = resolveServiceRoot(serviceRoot);
|
|
80
158
|
const resolvedContractPath = resolveContractPath(resolvedServiceRoot, contractPath);
|
|
81
159
|
const rawContract = readJsonFile(resolvedContractPath);
|
|
82
|
-
|
|
160
|
+
|
|
161
|
+
// Coverage is collected from the repository, so only this entry point can
|
|
162
|
+
// supply it. The env block is rejected outright without it rather than
|
|
163
|
+
// validated halfway (automation-gates.md §5).
|
|
164
|
+
const envCoverage = collectEnvCoverage({
|
|
165
|
+
serviceRoot: resolvedServiceRoot,
|
|
166
|
+
requiredConnectors: rawContract?.requiredConnectors
|
|
167
|
+
});
|
|
168
|
+
const normalizedContract = normalizeIntegrationContract(rawContract, resolvedContractPath, { envCoverage });
|
|
83
169
|
|
|
84
170
|
return {
|
|
85
171
|
serviceRoot: resolvedServiceRoot,
|
|
@@ -214,17 +300,6 @@ function formatEnvironmentOutput(env, format) {
|
|
|
214
300
|
throw new Error(`[BizCiGate] Unsupported output format - ${format}`);
|
|
215
301
|
}
|
|
216
302
|
|
|
217
|
-
function parseIntegerOrNull(value) {
|
|
218
|
-
if (value === undefined || value === null || value === '') {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
const parsed = Number.parseInt(String(value), 10);
|
|
222
|
-
if (!Number.isFinite(parsed)) {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
return parsed;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
303
|
function buildIntegrationSignalSummary(options) {
|
|
229
304
|
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
230
305
|
const unitTestFiles = getUnitTestFiles(contractInfo.serviceRoot);
|
|
@@ -239,10 +314,27 @@ function buildIntegrationSignalSummary(options) {
|
|
|
239
314
|
gateReason = options.gateReason || error.message;
|
|
240
315
|
}
|
|
241
316
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
317
|
+
// Executed figures come from the run artefact or from nowhere. The previous
|
|
318
|
+
// version fell back to the number of test FILES on disk, which reported tests
|
|
319
|
+
// as executed that had never run — the exact fake-green this gate exists to
|
|
320
|
+
// stop. No repo ever supplied the override it fell back from, so the artefact
|
|
321
|
+
// has always stated a fabricated number.
|
|
322
|
+
const runArtefact = readIntegrationRunArtefact(contractInfo.serviceRoot);
|
|
323
|
+
const executed = runArtefact
|
|
324
|
+
? {
|
|
325
|
+
source: 'runner',
|
|
326
|
+
integrationTests: runArtefact.artefact.executedTests ?? null,
|
|
327
|
+
notExecutedTests: runArtefact.artefact.notExecutedTests ?? null,
|
|
328
|
+
verdict: runArtefact.artefact.verdict ?? null,
|
|
329
|
+
artefactPath: runArtefact.artefactPath,
|
|
330
|
+
}
|
|
331
|
+
: {
|
|
332
|
+
source: 'unknown',
|
|
333
|
+
integrationTests: null,
|
|
334
|
+
notExecutedTests: null,
|
|
335
|
+
verdict: null,
|
|
336
|
+
artefactPath: null,
|
|
337
|
+
};
|
|
246
338
|
|
|
247
339
|
return {
|
|
248
340
|
generatedAt: new Date().toISOString(),
|
|
@@ -255,10 +347,7 @@ function buildIntegrationSignalSummary(options) {
|
|
|
255
347
|
unitTestFiles: unitTestFiles.length,
|
|
256
348
|
integrationTestFiles: integrationTestFiles.length,
|
|
257
349
|
},
|
|
258
|
-
executed
|
|
259
|
-
unitSuites: resolvedUnitExecuted,
|
|
260
|
-
integrationSuites: resolvedIntegrationExecuted,
|
|
261
|
-
},
|
|
350
|
+
executed,
|
|
262
351
|
gate: {
|
|
263
352
|
verdict: gateVerdict,
|
|
264
353
|
reason: gateReason,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Connector contract check — validation step "connectors".
|
|
5
|
+
*
|
|
6
|
+
* Replaces a step that printed "PASS (via cookbook tests)" and returned
|
|
7
|
+
* valid:true without evaluating anything. A step reporting a guarantee it never
|
|
8
|
+
* checked is worse than no step, because the log then reads as evidence.
|
|
9
|
+
*
|
|
10
|
+
* Tier-1 runs in phase 0.2, before the wrapper opens any connector, so nothing
|
|
11
|
+
* live can be probed here. What can be checked is that the service's two
|
|
12
|
+
* declarations of the same fact agree, and that the environment backs them:
|
|
13
|
+
*
|
|
14
|
+
* config/service/config.json wrapper.<connector> sections
|
|
15
|
+
* config/service/integration-contract.json requiredConnectors
|
|
16
|
+
*
|
|
17
|
+
* biz-converter shipped with requiredConnectors.db=false while owning a schema —
|
|
18
|
+
* six migrations, DB_HOST set, a database service in CI. Nothing caught it: the
|
|
19
|
+
* only consequence was that ci:gate:wait never waited for the database, so the
|
|
20
|
+
* suite raced it. It was found by hand during the F4 switchover. This step
|
|
21
|
+
* exists so that class of contradiction is found by the machine instead.
|
|
22
|
+
*
|
|
23
|
+
* Pure: takes the two declarations and an environment, returns a result. No
|
|
24
|
+
* filesystem, no network, no process exit.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How a required connector shows up in config.json's wrapper section, and which
|
|
29
|
+
* environment variable has to back it.
|
|
30
|
+
*
|
|
31
|
+
* `configSections` lists the wrapper keys that imply the connector, empty when
|
|
32
|
+
* none does. `cache` and `state` are both Redis-backed, so either satisfies
|
|
33
|
+
* redis. `db` and `minio` have no wrapper section — services reach their schema
|
|
34
|
+
* and object storage directly — so they are declared by the contract and
|
|
35
|
+
* evidenced only by the environment.
|
|
36
|
+
*
|
|
37
|
+
* The mapping was corrected on 2026-08-22 by running it against all eight
|
|
38
|
+
* services: an assumed wrapper.storage section for MinIO exists nowhere, and
|
|
39
|
+
* guessing it would have failed emailer and pdfgen for a configuration neither
|
|
40
|
+
* was ever meant to have.
|
|
41
|
+
*/
|
|
42
|
+
const CONNECTORS = {
|
|
43
|
+
db: { configSections: [], env: ['DB_HOST'] },
|
|
44
|
+
redis: { configSections: ['cache', 'state'], env: ['REDIS_URL'] },
|
|
45
|
+
mq: { configSections: ['mq'], env: ['RABBITMQ_URL'] },
|
|
46
|
+
// MinIO, like the database, has no wrapper section: services reach it through
|
|
47
|
+
// @onlineapps/conn-base-storage directly. Verified 2026-08-22 — no service on
|
|
48
|
+
// the platform declares wrapper.storage. So it is declared by the contract and
|
|
49
|
+
// evidenced only by the environment.
|
|
50
|
+
minio: { configSections: [], env: ['MINIO_ENDPOINT', 'MINIO_ACTUAL_HOST'] }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} args
|
|
55
|
+
* @param {object} args.config parsed config/service/config.json
|
|
56
|
+
* @param {object} args.requiredConnectors from the integration contract
|
|
57
|
+
* @param {object} args.env environment to check against
|
|
58
|
+
* @returns {{valid: boolean, checked: string[], errors: string[]}}
|
|
59
|
+
*/
|
|
60
|
+
function verifyConnectorContract({ config, requiredConnectors, env }) {
|
|
61
|
+
const errors = [];
|
|
62
|
+
const checked = [];
|
|
63
|
+
const wrapper = config?.wrapper ?? {};
|
|
64
|
+
|
|
65
|
+
for (const [name, spec] of Object.entries(CONNECTORS)) {
|
|
66
|
+
const required = requiredConnectors?.[name] === true;
|
|
67
|
+
const configured = spec.configSections.some((section) => wrapper[section] !== undefined);
|
|
68
|
+
|
|
69
|
+
if (required) {
|
|
70
|
+
checked.push(name);
|
|
71
|
+
|
|
72
|
+
if (spec.configSections.length > 0 && !configured) {
|
|
73
|
+
errors.push(`Connector "${name}" is required by the integration contract but no `
|
|
74
|
+
+ `wrapper.${spec.configSections.join('/wrapper.')} section configures it in config/service/config.json.\n`
|
|
75
|
+
+ ` Fix: configure it, or set requiredConnectors.${name} to false if the service does not use it.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const satisfied = spec.env.some((key) => env?.[key]);
|
|
79
|
+
if (!satisfied) {
|
|
80
|
+
errors.push(`Connector "${name}" is required but ${spec.env.join(' / ')} is not set.\n`
|
|
81
|
+
+ ' Fix: set it in the CI job or config/env-active/*.env — a required connector without its '
|
|
82
|
+
+ 'endpoint fails later, inside the connector, where the cause is harder to see.');
|
|
83
|
+
}
|
|
84
|
+
} else if (configured) {
|
|
85
|
+
errors.push(`config/service/config.json configures wrapper.${spec.configSections.find((s) => wrapper[s] !== undefined)} `
|
|
86
|
+
+ `but the integration contract says "${name}" is not required.\n`
|
|
87
|
+
+ ' The two declarations describe the same fact and must agree; while they disagree, '
|
|
88
|
+
+ 'ci:gate:wait does not wait for this connector and the suite races it.\n'
|
|
89
|
+
+ ` Fix: set requiredConnectors.${name} to true, or remove the wrapper section.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { valid: errors.length === 0, checked, errors };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { verifyConnectorContract, CONNECTORS };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cookbook format rule — the single place in this package that decides which
|
|
5
|
+
* cookbook format version the platform accepts.
|
|
6
|
+
*
|
|
7
|
+
* The rule itself is owned by the biz doc tree, not by this file:
|
|
8
|
+
* api/docs/biz/40-cookbooks/format.md § "Required fields" — `version` is a
|
|
9
|
+
* REQUIRED string, `"2.1.0"` or higher. This module only implements that
|
|
10
|
+
* sentence so both entry points enforce the same thing:
|
|
11
|
+
*
|
|
12
|
+
* - `CookbookTestRunner.validateCookbook()` — the Tier-1 gate every biz
|
|
13
|
+
* service runs at boot (mandatory, no per-repo opt-in);
|
|
14
|
+
* - `CookbookTestUtils.validateCookbook()` — the readiness-wrapper path
|
|
15
|
+
* three repos still call from `tests/bootstrap/service-readiness.test.js`.
|
|
16
|
+
*
|
|
17
|
+
* Returned messages carry the `Problem - Expected/Fix` part of the error
|
|
18
|
+
* format (architecture-principles §5); the `[Context]` prefix belongs to the
|
|
19
|
+
* caller, which is the one that knows whether it read a file or was handed an
|
|
20
|
+
* object.
|
|
21
|
+
*
|
|
22
|
+
* @see api/docs/biz/40-cookbooks/format.md
|
|
23
|
+
* @see api/docs/biz/40-cookbooks/test-runner-flow.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Minimum accepted cookbook format version — format.md § Required fields. */
|
|
27
|
+
const MIN_COOKBOOK_FORMAT_VERSION = '2.1.0';
|
|
28
|
+
|
|
29
|
+
/** `major[.minor[.patch]]`, digits only — the shape format.md documents. */
|
|
30
|
+
const VERSION_PATTERN = /^\d+(\.\d+){0,2}$/;
|
|
31
|
+
|
|
32
|
+
const DOC_REFERENCE = 'api/docs/biz/40-cookbooks/format.md § Required fields';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} version numeric version string, already pattern-checked
|
|
36
|
+
* @returns {number[]} exactly three components, missing ones are 0
|
|
37
|
+
*/
|
|
38
|
+
function toComponents(version) {
|
|
39
|
+
const parts = version.split('.').map((part) => parseInt(part, 10));
|
|
40
|
+
while (parts.length < 3) {
|
|
41
|
+
parts.push(0);
|
|
42
|
+
}
|
|
43
|
+
return parts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} a numeric version string
|
|
48
|
+
* @param {string} b numeric version string
|
|
49
|
+
* @returns {number} negative when a < b, 0 when equal, positive when a > b
|
|
50
|
+
*/
|
|
51
|
+
function compareVersions(a, b) {
|
|
52
|
+
const left = toComponents(a);
|
|
53
|
+
const right = toComponents(b);
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < 3; i++) {
|
|
56
|
+
if (left[i] !== right[i]) {
|
|
57
|
+
return left[i] - right[i];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check a cookbook's `version` field against the documented format rule.
|
|
65
|
+
*
|
|
66
|
+
* @param {*} version the raw value of the cookbook's top-level `version`
|
|
67
|
+
* @returns {string|null} `null` when the version satisfies the rule, otherwise
|
|
68
|
+
* the problem in `Problem - Expected/Fix` form
|
|
69
|
+
*/
|
|
70
|
+
function checkCookbookFormatVersion(version) {
|
|
71
|
+
if (version === undefined || version === null || version === '') {
|
|
72
|
+
return `cookbook format version is missing - Expected top-level "version": "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
73
|
+
+ `Fix: add "version": "${MIN_COOKBOOK_FORMAT_VERSION}" to the cookbook (${DOC_REFERENCE}).`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
|
|
77
|
+
return `cookbook format version ${JSON.stringify(version)} is not a version string - `
|
|
78
|
+
+ `Expected numeric "<major>.<minor>.<patch>", "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
79
|
+
+ `Fix: write the version as "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (compareVersions(version, MIN_COOKBOOK_FORMAT_VERSION) < 0) {
|
|
83
|
+
return `cookbook format version "${version}" is below the required minimum ${MIN_COOKBOOK_FORMAT_VERSION} - `
|
|
84
|
+
+ `Expected "${MIN_COOKBOOK_FORMAT_VERSION}" or higher. `
|
|
85
|
+
+ `Fix: migrate the cookbook to the v2.1 format and set "version": "${MIN_COOKBOOK_FORMAT_VERSION}" (${DOC_REFERENCE}).`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
MIN_COOKBOOK_FORMAT_VERSION,
|
|
93
|
+
checkCookbookFormatVersion,
|
|
94
|
+
compareVersions
|
|
95
|
+
};
|