@onlineapps/conn-orch-validator 6.0.1 → 8.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 +2591 -2
- package/README.md +1075 -7
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +422 -104
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +78 -42
- package/src/ValidationOrchestrator.js +298 -75
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +12 -2
- package/src/helpers/createServiceReadinessTests.js +75 -6
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +213 -13
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +21 -20
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +290 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +4 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +101 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ONE service, ONE name.
|
|
5
|
+
*
|
|
6
|
+
* `api/docs/biz/60-templates/naming.md` is normative and derives every spelling
|
|
7
|
+
* of a biz service from a single short name: `service.name` is `biz-<shortname>`,
|
|
8
|
+
* the npm package is `biz-<shortname>`, the container is
|
|
9
|
+
* `api_service_<shortname>`, the env template is `<shortname>.env`, the database
|
|
10
|
+
* is `oagen_<shortname>`, the directory is `<shortname>` and the git repository
|
|
11
|
+
* ends in `biz-<shortname>`. The document says why the last of those matters in
|
|
12
|
+
* a way no other node does: `service_code` in the entitlement data and the
|
|
13
|
+
* service name in a cookbook step are compared by the gateway CHARACTER BY
|
|
14
|
+
* CHARACTER, so a second spelling of one service is not untidiness — it is a
|
|
15
|
+
* request that is refused.
|
|
16
|
+
*
|
|
17
|
+
* Measured 2026-09-10 over the eight live services: seven wear one name each,
|
|
18
|
+
* and `hello-service` wears four — directory `hello-service`, repository
|
|
19
|
+
* `biz-hello-service`, env template `hello-service.env`, everything else
|
|
20
|
+
* `hello`. Nothing said a word about it, and the standard's own § Current State
|
|
21
|
+
* recorded the divergence as a fact rather than as a defect.
|
|
22
|
+
*
|
|
23
|
+
* Two rows, because they answer to two different owners:
|
|
24
|
+
*
|
|
25
|
+
* - **`C-IDENTITY`** compares the spellings INSIDE the repository against the
|
|
26
|
+
* short name that repository declares. It needs nothing but its own root, so
|
|
27
|
+
* it answers in a container and in the service's own CI too.
|
|
28
|
+
* - **`U-IDENTITY`** compares the SSOT's row for this service — `directory`,
|
|
29
|
+
* `repo`, `container` in `api/config/services.json` — against the same short
|
|
30
|
+
* name. Those three are facts of the workspace, so the row is only ever
|
|
31
|
+
* asked where the workspace is.
|
|
32
|
+
*
|
|
33
|
+
* The short name is read the one way this package reads it, from
|
|
34
|
+
* `config/service/config.json` → `service.name` (`serviceIdentity.js`); the
|
|
35
|
+
* directory name is never the source, because inside the image every service
|
|
36
|
+
* lies in `/app`.
|
|
37
|
+
*
|
|
38
|
+
* @see api/docs/biz/60-templates/naming.md
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
|
|
44
|
+
const { whereOf } = require('./libraryContext');
|
|
45
|
+
const { readComposeServices } = require('./composeShape');
|
|
46
|
+
const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
47
|
+
const { PLACEHOLDERS } = require('../../sync/serviceTemplate');
|
|
48
|
+
const { resolveFromMap } = require('../discovery');
|
|
49
|
+
|
|
50
|
+
/** Where a service declares the name npm knows it by. */
|
|
51
|
+
const PACKAGE_FILE = 'package.json';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where a service declares its own name to the platform gates and its database,
|
|
55
|
+
* if it has one. `C-CONTRACT` owns the FILE — whether each declaration is there
|
|
56
|
+
* and well-formed; this row owns only whether the two names it carries are this
|
|
57
|
+
* service's.
|
|
58
|
+
*/
|
|
59
|
+
const CONTRACT_FILE = 'config/service/integration-contract.json';
|
|
60
|
+
|
|
61
|
+
/** The directory every per-service env template lies in. */
|
|
62
|
+
const ENV_TEMPLATE_DIR = 'config/env-templates';
|
|
63
|
+
|
|
64
|
+
/** The env template every service shares; it is named after the platform, not the service. */
|
|
65
|
+
const SHARED_ENV = 'shared.env';
|
|
66
|
+
|
|
67
|
+
/** The prefix `naming.md` gives every biz database. */
|
|
68
|
+
const DATABASE_PREFIX = 'oagen_';
|
|
69
|
+
|
|
70
|
+
/** The profile marking the one-shot test runner, which declares no container name. */
|
|
71
|
+
const TEST_PROFILE = 'test';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a file of the repository under check.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} serviceRoot repository root
|
|
77
|
+
* @param {string} relative repository-relative path, `/`-separated
|
|
78
|
+
* @returns {string|null} null when it is not there
|
|
79
|
+
*/
|
|
80
|
+
function readOrNull(serviceRoot, relative) {
|
|
81
|
+
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
82
|
+
return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A JSON file of the repository, or null when it is absent or unreadable. A
|
|
87
|
+
* broken file is somebody else's row (`C-SERVICE`, `C-CONTRACT`), and this row
|
|
88
|
+
* saying so a second time would be a second owner of one defect.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} serviceRoot repository root
|
|
91
|
+
* @param {string} relative repository-relative path
|
|
92
|
+
* @returns {object|null}
|
|
93
|
+
*/
|
|
94
|
+
function readJson(serviceRoot, relative) {
|
|
95
|
+
const text = readOrNull(serviceRoot, relative);
|
|
96
|
+
if (text === null) return null;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The env templates a repository carries, the shared one excluded — it is named
|
|
106
|
+
* after the platform and every service has the same copy (`G-SHARED-ENV`).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} serviceRoot repository root
|
|
109
|
+
* @returns {string[]|null} file names, or null when the directory is absent
|
|
110
|
+
*/
|
|
111
|
+
function envTemplates(serviceRoot) {
|
|
112
|
+
const dir = path.join(serviceRoot, ...ENV_TEMPLATE_DIR.split('/'));
|
|
113
|
+
if (!fs.existsSync(dir)) return null;
|
|
114
|
+
return fs.readdirSync(dir).filter((name) => name !== SHARED_ENV).sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every `env_file` entry of every service block of a compose file, as written.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} text the compose file
|
|
121
|
+
* @returns {string[]}
|
|
122
|
+
*/
|
|
123
|
+
function envFileEntries(text) {
|
|
124
|
+
return [...readComposeServices(text).values()]
|
|
125
|
+
.flatMap((node) => node.sequences.get('env_file') || [])
|
|
126
|
+
.map((entry) => entry.trim());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The container names a compose file declares, the one-shot runner excluded: it
|
|
131
|
+
* declares none on purpose, and `F-RUNNER` owns that block.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} text the compose file
|
|
134
|
+
* @returns {Array<[string, string]>} `[compose service, container_name]`
|
|
135
|
+
*/
|
|
136
|
+
function containerNames(text) {
|
|
137
|
+
return [...readComposeServices(text).entries()]
|
|
138
|
+
.filter(([, node]) => !(node.sequences.get('profiles') || []).includes(TEST_PROFILE))
|
|
139
|
+
.map(([name, node]) => [name, node.scalars.get('container_name')])
|
|
140
|
+
.filter(([, container]) => typeof container === 'string' && container !== '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const serviceIdentity = Object.freeze({
|
|
144
|
+
scope: 'bearer',
|
|
145
|
+
requires: Object.freeze(['path', 'production_path']),
|
|
146
|
+
|
|
147
|
+
// Nothing outside this repository is read, so the row answers wherever the
|
|
148
|
+
// repository is — in the container and in the service's own CI included.
|
|
149
|
+
needsWorkspace: () => false,
|
|
150
|
+
|
|
151
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
152
|
+
const identity = readIdentity(serviceRoot);
|
|
153
|
+
const at = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
|
|
154
|
+
|
|
155
|
+
if (identity.problem !== undefined) {
|
|
156
|
+
if (identity.undecided === null) return [];
|
|
157
|
+
return [{ where: at(IDENTITY_FILE), what: `the spellings could not be compared — ${identity.undecided}` }];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { service_name: shortName, container_name: container } = identity.params;
|
|
161
|
+
const findings = [];
|
|
162
|
+
|
|
163
|
+
// The npm name is compared against what `config/service/config.json`
|
|
164
|
+
// DECLARES, character for character — naming.md gives the two the same
|
|
165
|
+
// spelling, so one name is the whole question. Comparing it against the
|
|
166
|
+
// derived `biz-<shortname>` instead would smuggle in a rule about
|
|
167
|
+
// `service.name` itself that no row owns (`serviceIdentity.js` § REGISTERED_NAME
|
|
168
|
+
// says so in as many words); that is a change to the rules, not a side
|
|
169
|
+
// effect of this one (`truth-over-agreement.md` §6).
|
|
170
|
+
const declaredName = (readJson(serviceRoot, IDENTITY_FILE) || {}).service.name;
|
|
171
|
+
const pkg = readJson(serviceRoot, PACKAGE_FILE);
|
|
172
|
+
if (pkg !== null && pkg.name !== declaredName) {
|
|
173
|
+
findings.push({
|
|
174
|
+
where: at(PACKAGE_FILE),
|
|
175
|
+
what: `npm name is ${JSON.stringify(pkg.name)}, `
|
|
176
|
+
+ `and ${IDENTITY_FILE} declares ${JSON.stringify(declaredName)}`
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The SIXTH spelling, and the one this row missed until d.218: the
|
|
181
|
+
// integration contract opens with `serviceName`, and it is the first file
|
|
182
|
+
// every platform gate reads. Compared against what `config/service/config.json`
|
|
183
|
+
// DECLARES, character for character, for the same reason the npm name is:
|
|
184
|
+
// whether `service.name` itself has to read `biz-<name>` is a question no row
|
|
185
|
+
// owns, and this one is not the place a rule appears as a side effect
|
|
186
|
+
// (`truth-over-agreement.md` §6). A contract that declares nothing here, or
|
|
187
|
+
// none at all, is `C-CONTRACT`'s finding — it calls the validator owning
|
|
188
|
+
// every rule of that file, and one defect has one owner
|
|
189
|
+
// (`change-discipline.md` § One rail per concern).
|
|
190
|
+
const contractName = (readJson(serviceRoot, CONTRACT_FILE) || {}).serviceName;
|
|
191
|
+
if (typeof contractName === 'string' && contractName !== declaredName) {
|
|
192
|
+
findings.push({
|
|
193
|
+
where: at(CONTRACT_FILE),
|
|
194
|
+
what: `serviceName is ${JSON.stringify(contractName)}, `
|
|
195
|
+
+ `and ${IDENTITY_FILE} declares ${JSON.stringify(declaredName)}`
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const relative of [row.path, row.production_path]) {
|
|
200
|
+
const text = readOrNull(serviceRoot, relative);
|
|
201
|
+
if (text === null) continue;
|
|
202
|
+
|
|
203
|
+
for (const [composeService, declared] of containerNames(text)) {
|
|
204
|
+
if (declared === container) continue;
|
|
205
|
+
findings.push({
|
|
206
|
+
where: at(relative),
|
|
207
|
+
what: `service ${composeService} is called ${JSON.stringify(declared)}, `
|
|
208
|
+
+ `and the name derived from ${IDENTITY_FILE} is ${JSON.stringify(container)}`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const expectedEnv = `${shortName}.env`;
|
|
214
|
+
const templates = envTemplates(serviceRoot);
|
|
215
|
+
if (templates !== null && !templates.includes(expectedEnv)) {
|
|
216
|
+
findings.push({
|
|
217
|
+
where: at(ENV_TEMPLATE_DIR),
|
|
218
|
+
what: templates.length === 0
|
|
219
|
+
? `carries no env template of its own — ${expectedEnv} is the one this service is named after`
|
|
220
|
+
: `carries ${templates.join(', ')}, and this service's env template is ${expectedEnv}`
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const relative of [row.path, row.production_path]) {
|
|
225
|
+
const text = readOrNull(serviceRoot, relative);
|
|
226
|
+
if (text === null) continue;
|
|
227
|
+
|
|
228
|
+
for (const entry of envFileEntries(text)) {
|
|
229
|
+
const name = entry.split('/').pop();
|
|
230
|
+
if (name === SHARED_ENV || name === expectedEnv) continue;
|
|
231
|
+
findings.push({
|
|
232
|
+
where: at(relative),
|
|
233
|
+
what: `env_file loads ${JSON.stringify(entry)}, and this service's env template is ${expectedEnv}`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Only where the repository DECLARES a database. A stateless service
|
|
239
|
+
// declares none, and demanding one would be this row inventing a rule
|
|
240
|
+
// `naming.md` does not state (`pdfgen` is the measured case).
|
|
241
|
+
const contract = readJson(serviceRoot, CONTRACT_FILE);
|
|
242
|
+
const schema = contract === null || contract.database === undefined || contract.database === null
|
|
243
|
+
? null
|
|
244
|
+
: contract.database.schema;
|
|
245
|
+
if (typeof schema === 'string' && schema !== `${DATABASE_PREFIX}${shortName}`) {
|
|
246
|
+
findings.push({
|
|
247
|
+
where: at(CONTRACT_FILE),
|
|
248
|
+
what: `database.schema is ${JSON.stringify(schema)}, and the name derived from ${IDENTITY_FILE} `
|
|
249
|
+
+ `is ${JSON.stringify(`${DATABASE_PREFIX}${shortName}`)}`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return findings;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The three fields of the SSOT row that spell this service's name, and what each
|
|
259
|
+
* has to read. `container` is the same derivation `C-IDENTITY` holds the compose
|
|
260
|
+
* files to, which is the point: the SSOT and the repository are two places
|
|
261
|
+
* saying one thing.
|
|
262
|
+
*/
|
|
263
|
+
const SSOT_FIELDS = Object.freeze([
|
|
264
|
+
{ field: 'directory', of: (params) => params.service_name, says: 'the directory this service lies in' },
|
|
265
|
+
{ field: 'repo', of: (params) => params.repo_name, says: 'the last segment of the git repository path', last: true },
|
|
266
|
+
{ field: 'container', of: (params) => params.container_name, says: 'the container this service runs as' }
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
const ssotIdentity = Object.freeze({
|
|
270
|
+
scope: 'bearer',
|
|
271
|
+
requires: Object.freeze([]),
|
|
272
|
+
|
|
273
|
+
describeNotRun({ block }) {
|
|
274
|
+
return `the workspace root is not reachable, so ${block.from.path} cannot be read`;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
run({ block, serviceRoot, workspaceRoot }) {
|
|
278
|
+
const identity = readIdentity(serviceRoot);
|
|
279
|
+
const at = (relative) => whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative });
|
|
280
|
+
|
|
281
|
+
if (identity.problem !== undefined) {
|
|
282
|
+
if (identity.undecided === null) return [];
|
|
283
|
+
return [{ where: at(IDENTITY_FILE), what: `the SSOT row could not be found — ${identity.undecided}` }];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const registered = identity.params.registry_name;
|
|
287
|
+
|
|
288
|
+
// The TEMPLATE is a bearer of every FILE row and of none of the SSOT ones:
|
|
289
|
+
// it declares its identity as the placeholder (`serviceIdentity.js`), and
|
|
290
|
+
// `api/config/services.json` lists services, of which the template is not
|
|
291
|
+
// one — it is the shape they are made from. The question this row asks does
|
|
292
|
+
// not apply to it, the same way `appliesTo` decides a library duty by
|
|
293
|
+
// category. Not a silence about a service: a service always declares a name
|
|
294
|
+
// of its own, and the case below reports one the SSOT does not know.
|
|
295
|
+
if (registered === PLACEHOLDERS.registry_name) return [];
|
|
296
|
+
|
|
297
|
+
const entries = resolveFromMap({ from: block.from, workspaceRoot });
|
|
298
|
+
const rows = Array.isArray(entries) ? entries : [];
|
|
299
|
+
const declared = rows.filter((entry) => entry.name === registered);
|
|
300
|
+
|
|
301
|
+
if (declared.length === 0) {
|
|
302
|
+
// A bearer the SSOT knows under NO name and under NO directory is
|
|
303
|
+
// `U-ORPHAN`'s finding, and saying it twice would give one defect two
|
|
304
|
+
// owners (`change-discipline.md` § One rail per concern). What reaches
|
|
305
|
+
// here is the other case: the SSOT declares this directory, and calls the
|
|
306
|
+
// service living in it something else.
|
|
307
|
+
const directory = path.basename(path.resolve(serviceRoot));
|
|
308
|
+
if (!rows.some((entry) => entry[block.from.key] === directory)) return [];
|
|
309
|
+
|
|
310
|
+
return [{
|
|
311
|
+
where: block.from.path,
|
|
312
|
+
what: `declares directory ${JSON.stringify(directory)} under another name, and no `
|
|
313
|
+
+ `${JSON.stringify(block.from.list)} entry is named ${JSON.stringify(registered)} — `
|
|
314
|
+
+ `which is what ${IDENTITY_FILE} calls this service`
|
|
315
|
+
}];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const findings = [];
|
|
319
|
+
for (const entry of declared) {
|
|
320
|
+
for (const { field, of, says, last } of SSOT_FIELDS) {
|
|
321
|
+
const value = entry[field];
|
|
322
|
+
if (typeof value !== 'string' || value === '') {
|
|
323
|
+
findings.push({
|
|
324
|
+
where: block.from.path,
|
|
325
|
+
what: `${JSON.stringify(registered)} declares no ${JSON.stringify(field)} — it names ${says}`
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const compared = last === true ? value.split('/').pop() : value;
|
|
330
|
+
const expected = of(identity.params);
|
|
331
|
+
if (compared === expected) continue;
|
|
332
|
+
findings.push({
|
|
333
|
+
where: block.from.path,
|
|
334
|
+
what: `${JSON.stringify(registered)} declares ${field} ${JSON.stringify(value)}, and the name `
|
|
335
|
+
+ `derived from ${IDENTITY_FILE} is ${JSON.stringify(expected)} — it names ${says}`
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return findings;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
checks: [
|
|
345
|
+
{ name: 'service-identity', check: serviceIdentity },
|
|
346
|
+
{ name: 'ssot-identity', check: ssotIdentity }
|
|
347
|
+
],
|
|
348
|
+
DATABASE_PREFIX,
|
|
349
|
+
envFileEntries,
|
|
350
|
+
containerNames
|
|
351
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a service is allowed to BE at run time: which Node major it runs, how
|
|
5
|
+
* much memory it may hold, which process it runs AS, and the ports it may not
|
|
6
|
+
* publish.
|
|
7
|
+
*
|
|
8
|
+
* Every row here is a measured gap the existing gates leave open, not a second
|
|
9
|
+
* copy of them (004 point 4):
|
|
10
|
+
*
|
|
11
|
+
* - `R3` of the deploy contract compares CI, Dockerfile and `engines.node`
|
|
12
|
+
* against EACH OTHER. Five of the eight services are internally consistent
|
|
13
|
+
* on an older major while the platform has moved, so R3 is green and the
|
|
14
|
+
* drift is invisible. `R-NODE` compares them against the platform major, and
|
|
15
|
+
* is the one row this uniform fills by itself.
|
|
16
|
+
* - `R4` refuses published ports in the PRODUCTION compose. `R-PORTS-DEV` is
|
|
17
|
+
* the dev compose, which R4 never reads.
|
|
18
|
+
* - the memory norm has no gate at all today: `biz-memory-limits.md` 001 is
|
|
19
|
+
* prose, and two services sit at 384M in dev and in production.
|
|
20
|
+
* - `biz-compose-pid1` 001 was prose too, and 002 says where it belongs: in
|
|
21
|
+
* the uniform. `R-PID1` is the row, and until it existed the decision
|
|
22
|
+
* travelled by being remembered.
|
|
23
|
+
*
|
|
24
|
+
* The Node major is REFERENCED, never restated. Until d.238 the reference was
|
|
25
|
+
* `api/.nvmrc`, the file that owns it — and that made the row unanswerable in
|
|
26
|
+
* the two places a service is actually built: inside its container and in its
|
|
27
|
+
* own CI checkout, neither of which has a workspace. It now reads the
|
|
28
|
+
* `engines.node` of THIS package, which the library uniform's `L-ENGINES` fails
|
|
29
|
+
* any package for disagreeing with `api/.nvmrc`. So it is the same fact, one
|
|
30
|
+
* checked hop further along, and it travels with the pin (confirmation
|
|
31
|
+
* `biz-service-manifest` 006 point 2).
|
|
32
|
+
*
|
|
33
|
+
* The two memory values are carried by the row, because no machine-readable
|
|
34
|
+
* owner for them exists yet and a row's `doc` naming the owner's decision is the
|
|
35
|
+
* honest second best.
|
|
36
|
+
*
|
|
37
|
+
* @see api/docs/governance/confirmations/biz-memory-limits.md
|
|
38
|
+
* @see api/docs/governance/confirmations/biz-compose-pid1.md
|
|
39
|
+
* @see api/docs/governance/confirmations/biz-service-manifest.md
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
|
|
45
|
+
const { resolveFromValue, isPackageReference, referenceOwner } = require('../discovery');
|
|
46
|
+
const { whereOf } = require('./libraryContext');
|
|
47
|
+
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
48
|
+
|
|
49
|
+
/** Where a service block states its own budget, in both compose files. */
|
|
50
|
+
const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
|
|
51
|
+
|
|
52
|
+
/** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
|
|
53
|
+
const TEST_PROFILE = 'test';
|
|
54
|
+
|
|
55
|
+
const readOrNull = (serviceRoot, relative) => {
|
|
56
|
+
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
57
|
+
return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The major of a Node version however it is written: `24`, `24-alpine`, `>=24.0.0 <25`. */
|
|
61
|
+
function majorOf(text) {
|
|
62
|
+
const match = /(\d+)/.exec(String(text));
|
|
63
|
+
return match === null ? null : Number(match[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The services of a compose file that are not the one-shot test runner. */
|
|
67
|
+
const runtimeServices = (text) => [...readComposeServices(text).entries()]
|
|
68
|
+
.filter(([, node]) => !(node.sequences.get('profiles') || []).includes(TEST_PROFILE));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The range the referenced `package.json` declares in `engines.node`.
|
|
72
|
+
*
|
|
73
|
+
* The reference names the FILE; which field carries the major is this check's
|
|
74
|
+
* business, and it is the same field the check compares every service against —
|
|
75
|
+
* one concept, asked twice (`change-discipline.md` § One rail per concern).
|
|
76
|
+
*
|
|
77
|
+
* @param {{ row: object, workspaceRoot: string|null }} params
|
|
78
|
+
* @returns {string} the range as the file writes it
|
|
79
|
+
*/
|
|
80
|
+
function platformRange({ row, workspaceRoot }) {
|
|
81
|
+
const raw = resolveFromValue({ from: row.from, workspaceRoot });
|
|
82
|
+
const owner = referenceOwner(row.from);
|
|
83
|
+
|
|
84
|
+
let document;
|
|
85
|
+
try {
|
|
86
|
+
document = JSON.parse(raw);
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new Error(`[ManifestRuntime] Platform Node major unreadable - ${owner} is not valid JSON. `
|
|
89
|
+
+ 'Fix: repair the file; its engines.node is the major every service is measured against.', { cause });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const declared = (document.engines || {}).node;
|
|
93
|
+
if (typeof declared !== 'string' || declared.length === 0) {
|
|
94
|
+
throw new Error(`[ManifestRuntime] Platform Node major unreadable - ${owner} declares no engines.node. `
|
|
95
|
+
+ 'Fix: declare it; the library uniform\'s L-ENGINES keeps it equal to api/.nvmrc, and this row reads it.');
|
|
96
|
+
}
|
|
97
|
+
return declared;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const nodeMajor = Object.freeze({
|
|
101
|
+
scope: 'bearer',
|
|
102
|
+
requires: Object.freeze(['from']),
|
|
103
|
+
|
|
104
|
+
// A reference into this package travels with the pin, so the row answers in a
|
|
105
|
+
// container and in the service's own CI too (d.229 § describeFromProblem).
|
|
106
|
+
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
107
|
+
|
|
108
|
+
describeNotRun({ row }) {
|
|
109
|
+
return `the workspace root is not reachable, so ${referenceOwner(row.from)} cannot be read`;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
113
|
+
const range = platformRange({ row, workspaceRoot });
|
|
114
|
+
const platform = majorOf(range);
|
|
115
|
+
if (platform === null) {
|
|
116
|
+
throw new Error('[ManifestRuntime] Platform Node major unreadable - '
|
|
117
|
+
+ `${referenceOwner(row.from)} declares engines.node as ${JSON.stringify(range)}, which carries no number. `
|
|
118
|
+
+ 'Fix: repair the file; it is what every service is measured against.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const findings = [];
|
|
122
|
+
const dockerfile = readOrNull(serviceRoot, 'Dockerfile');
|
|
123
|
+
if (dockerfile === null) {
|
|
124
|
+
findings.push({
|
|
125
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'Dockerfile' }),
|
|
126
|
+
what: 'absent — the image this service runs as is built from it'
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
const from = /^FROM\s+node:(\S+)/m.exec(dockerfile);
|
|
130
|
+
const major = from === null ? null : majorOf(from[1]);
|
|
131
|
+
if (major !== platform) {
|
|
132
|
+
findings.push({
|
|
133
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'Dockerfile' }),
|
|
134
|
+
what: from === null
|
|
135
|
+
? `does not build on a node: image, and the platform major is ${platform}`
|
|
136
|
+
: `builds on node:${from[1].split('-')[0]}, and the platform major is ${platform}`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pkg = readOrNull(serviceRoot, 'package.json');
|
|
142
|
+
const declared = pkg === null ? null : (JSON.parse(pkg).engines || {}).node;
|
|
143
|
+
if (typeof declared !== 'string' || declared.length === 0) {
|
|
144
|
+
findings.push({
|
|
145
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'package.json' }),
|
|
146
|
+
what: `declares no engines.node, and the platform major is ${platform}`
|
|
147
|
+
});
|
|
148
|
+
} else if (majorOf(declared) !== platform) {
|
|
149
|
+
findings.push({
|
|
150
|
+
where: whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: 'package.json' }),
|
|
151
|
+
what: `engines.node is ${JSON.stringify(declared)}, whose major is ${majorOf(declared)}, `
|
|
152
|
+
+ `and the platform major is ${platform}`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const memoryLimit = Object.freeze({
|
|
161
|
+
scope: 'service',
|
|
162
|
+
requires: Object.freeze(['path', 'production_path', 'service_memory']),
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The same number in both files, because the norm names dev AND production
|
|
166
|
+
* together — production is not the place to discover a different limit.
|
|
167
|
+
*/
|
|
168
|
+
run({ row, serviceRoot }) {
|
|
169
|
+
const findings = [];
|
|
170
|
+
|
|
171
|
+
for (const relative of [row.path, row.production_path]) {
|
|
172
|
+
const text = readOrNull(serviceRoot, relative);
|
|
173
|
+
if (text === null) {
|
|
174
|
+
findings.push({ where: relative, what: 'absent — the service is run from it' });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [name, node] of runtimeServices(text)) {
|
|
179
|
+
const declared = scalarAt(node, SERVICE_MEMORY_PATH);
|
|
180
|
+
if (declared === row.service_memory) continue;
|
|
181
|
+
findings.push({
|
|
182
|
+
where: relative,
|
|
183
|
+
what: declared === null
|
|
184
|
+
? `service ${name} declares no ${SERVICE_MEMORY_PATH} — the platform norm is ${row.service_memory}`
|
|
185
|
+
: `the service declares ${declared}, the platform norm is ${row.service_memory}`
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return findings;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The argv a `command:` declaration means, whichever of the two shapes compose
|
|
196
|
+
* accepts it is written in — `["node", "index.js"]` or `node index.js`. Two
|
|
197
|
+
* spellings of one decision, and a row grading the spelling would report a
|
|
198
|
+
* service that is already right.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} declared the declaration verbatim
|
|
201
|
+
* @returns {string[]|null} null when there is nothing there, or when the
|
|
202
|
+
* bracket form is not readable — an unreadable command is a finding, not a pass
|
|
203
|
+
*/
|
|
204
|
+
function argvOf(declared) {
|
|
205
|
+
const text = String(declared).trim();
|
|
206
|
+
if (text === '') return null;
|
|
207
|
+
if (!text.startsWith('[')) return text.split(/\s+/);
|
|
208
|
+
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return Array.isArray(parsed) ? parsed.map(String) : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `R-PID1` — the process the container runs AS.
|
|
220
|
+
*
|
|
221
|
+
* `command: ["npm","start"]` makes npm process 1, and npm does not forward
|
|
222
|
+
* SIGTERM: the stop is a ten-second wait followed by SIGKILL, so a service never
|
|
223
|
+
* gets to close its channels. Confirmation `biz-compose-pid1` 001 settled the
|
|
224
|
+
* substance ("Ano, srovnat na node") and 002 settled where it belongs: not a
|
|
225
|
+
* per-repo choice any more but part of the uniform biz service shape, which is
|
|
226
|
+
* this row.
|
|
227
|
+
*
|
|
228
|
+
* The one-shot test runner is left alone on purpose — it declares no command
|
|
229
|
+
* because `docker compose run` supplies one, and its shape is F-RUNNER's row.
|
|
230
|
+
*
|
|
231
|
+
* @see api/docs/governance/confirmations/biz-compose-pid1.md
|
|
232
|
+
*/
|
|
233
|
+
const composeCommand = Object.freeze({
|
|
234
|
+
scope: 'service',
|
|
235
|
+
requires: Object.freeze(['path', 'service_command']),
|
|
236
|
+
|
|
237
|
+
run({ row, serviceRoot }) {
|
|
238
|
+
// The row states the command the way a person writes it; which of the two
|
|
239
|
+
// compose spellings a service happens to use is not the row's business.
|
|
240
|
+
const expected = argvOf(row.service_command);
|
|
241
|
+
if (expected === null) {
|
|
242
|
+
throw new Error('[ManifestRuntime] Platform command unreadable - row '
|
|
243
|
+
+ `${row.id} declares service_command as ${JSON.stringify(row.service_command)}, which names no command. `
|
|
244
|
+
+ 'Fix: state it as the argv a container runs, e.g. "node index.js".');
|
|
245
|
+
}
|
|
246
|
+
const wanted = JSON.stringify(expected);
|
|
247
|
+
const why = `the platform shape is ${wanted}, so PID 1 is node and a SIGTERM reaches it`;
|
|
248
|
+
|
|
249
|
+
const text = readOrNull(serviceRoot, row.path);
|
|
250
|
+
if (text === null) return [{ where: row.path, what: 'absent — the service is run from it' }];
|
|
251
|
+
|
|
252
|
+
const findings = [];
|
|
253
|
+
for (const [name, node] of runtimeServices(text)) {
|
|
254
|
+
const declared = declarationOf(node, 'command');
|
|
255
|
+
const argv = argvOf(declared);
|
|
256
|
+
|
|
257
|
+
if (argv === null && declared.trim() === '') {
|
|
258
|
+
findings.push({ where: row.path, what: `service ${name} declares no command — ${why}` });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (argv !== null && argv.length === expected.length && argv.every((word, i) => word === expected[i])) continue;
|
|
262
|
+
|
|
263
|
+
findings.push({ where: row.path, what: `service ${name} runs as ${declared.trim()} — ${why}` });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return findings;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const composeNoPorts = Object.freeze({
|
|
271
|
+
scope: 'service',
|
|
272
|
+
requires: Object.freeze(['path']),
|
|
273
|
+
|
|
274
|
+
run({ row, serviceRoot }) {
|
|
275
|
+
const text = readOrNull(serviceRoot, row.path);
|
|
276
|
+
if (text === null) return [];
|
|
277
|
+
|
|
278
|
+
return [...readComposeServices(text).entries()]
|
|
279
|
+
.filter(([, node]) => declares(node, 'ports'))
|
|
280
|
+
.map(([name]) => ({
|
|
281
|
+
where: row.path,
|
|
282
|
+
what: `service ${name} publishes a port — a biz service binds nothing, and work arrives over MQ`
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
checks: [
|
|
289
|
+
{ name: 'node-major', check: nodeMajor },
|
|
290
|
+
{ name: 'memory-limit', check: memoryLimit },
|
|
291
|
+
{ name: 'compose-command', check: composeCommand },
|
|
292
|
+
{ name: 'compose-no-ports', check: composeNoPorts }
|
|
293
|
+
],
|
|
294
|
+
majorOf
|
|
295
|
+
};
|