@onlineapps/conn-orch-validator 9.0.0 → 11.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 +546 -0
- package/README.md +337 -19
- package/docs/DESIGN.md +32 -9
- package/manifests/biz-service.manifest.json +56 -6
- package/package.json +3 -2
- package/src/CookbookTestRunner.js +134 -22
- package/src/ValidationOrchestrator.js +312 -73
- package/src/cli/biz-ci-gate.js +191 -15
- package/src/cli/oa-sync-template.js +23 -8
- package/src/cli/oa-validate.js +70 -2
- package/src/index.js +21 -13
- package/src/lint/scripts/lintScripts.js +65 -18
- package/src/manifest/checks/composeRunnerBlock.js +37 -20
- package/src/manifest/checks/discoveryOrphan.js +2 -1
- package/src/manifest/checks/docsLintBridge.js +79 -21
- package/src/manifest/checks/gitTracked.js +14 -28
- package/src/manifest/checks/libraryPackage.js +3 -1
- package/src/manifest/checks/libraryWorkspace.js +18 -3
- package/src/manifest/checks/readmeRegion.js +9 -1
- package/src/manifest/checks/serviceConfig.js +29 -12
- package/src/manifest/checks/serviceDb.js +176 -7
- package/src/manifest/checks/serviceFiles.js +34 -7
- package/src/manifest/checks/serviceIdentityRows.js +3 -1
- package/src/manifest/checks/serviceRuntime.js +126 -1
- package/src/manifest/discovery.js +25 -7
- package/src/manifest/gitCheckout.js +84 -0
- package/src/manifest/runManifest.js +58 -7
- package/src/manifest/workspaceRoot.js +91 -5
- package/src/sync/serviceTemplate.js +76 -7
- package/src/sync/sharedEnv.js +11 -4
- package/src/sync/uniformFiles.js +91 -21
- package/src/utils/bizCiGateContract.js +25 -1
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/installContract.js +46 -5
- package/src/utils/libCompat.js +39 -19
- package/src/utils/preValidation.js +56 -11
- package/src/utils/stepFailure.js +106 -19
- package/src/utils/stepReferences.js +278 -0
- package/src/utils/testCoverageContract.js +60 -2
- package/src/utils/throwawaySchema.js +92 -7
- package/src/validatorIdentity.js +31 -0
- package/src/validators/ServiceStructureValidator.js +47 -15
- package/src/validators/ValidationProofGenerator.js +73 -34
- package/templates/business-service/.dockerignore +9 -1
- package/templates/business-service/.gitlab-ci.yml +199 -35
- package/templates/business-service/Dockerfile +49 -16
- package/templates/business-service/README.md +56 -9
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +17 -5
- package/templates/business-service/config/env-templates/shared.env +8 -2
- package/templates/business-service/docker-compose.production.yml +9 -0
- package/templates/business-service/docker-compose.yml +17 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +1 -1
- package/templates/business-service/docs/80-setup/VALIDATION.md +1 -1
- package/templates/business-service/jest.config.js +9 -1
- package/templates/business-service/package.json.template +1 -1
- package/src/mocks/MockStorage.js +0 -188
package/src/cli/biz-ci-gate.js
CHANGED
|
@@ -33,8 +33,11 @@ const {
|
|
|
33
33
|
PLATFORM_TEST_FILE_PATTERN,
|
|
34
34
|
} = require('../utils/testCoverageContract');
|
|
35
35
|
const { runPreValidation } = require('../utils/preValidation');
|
|
36
|
+
const { describeRecordIdentity } = require('../utils/stepFailure');
|
|
36
37
|
const { checkLibCompat, loadLibrarySet } = require('../utils/libCompat');
|
|
37
|
-
const { buildSchema } = require('../utils/setupDatabase');
|
|
38
|
+
const { buildSchema, defaultExec } = require('../utils/setupDatabase');
|
|
39
|
+
const { databaseAccountSql, ACCOUNT_HOST } = require('../utils/dbAccountGrants');
|
|
40
|
+
const { readDeclaredAccount, ACCOUNT_KEY, ENV_TEMPLATE_DIR } = require('../manifest/checks/serviceDb');
|
|
38
41
|
|
|
39
42
|
function parseArgs(argv) {
|
|
40
43
|
const parsed = {
|
|
@@ -89,7 +92,7 @@ Commands:
|
|
|
89
92
|
contract's closed vocabularies on every live migrations/**/*.sql
|
|
90
93
|
(archive/ and superseded/ excluded), and each BASELINE manifest
|
|
91
94
|
naming exactly the files the installer applies, in that order.
|
|
92
|
-
run-prevalidation Run the service's cookbooks
|
|
95
|
+
run-prevalidation Run the service's cookbooks in-process (no transport, real database)
|
|
93
96
|
and write conn-runtime/validation-proof.json. Replaces the
|
|
94
97
|
per-service scripts/run-pre-validation.js.
|
|
95
98
|
verify-lib-compat Compare @onlineapps/* pins against the platform library SSOT (R6).
|
|
@@ -100,6 +103,10 @@ Commands:
|
|
|
100
103
|
skipped; this reads the runner's own JSON report.
|
|
101
104
|
emit-ci-env Emit connector-aware CI environment exports.
|
|
102
105
|
wait-connectors Wait for required connector TCP endpoints.
|
|
106
|
+
setup-db-account Create this service's database account and grant it its own schemas.
|
|
107
|
+
The ONE step of a biz pipeline that runs under the database root
|
|
108
|
+
account, and the last one that does: every step after it connects
|
|
109
|
+
as the service, the way production does.
|
|
103
110
|
setup-db Build the schema from the declared migrations (contract database block).
|
|
104
111
|
run-setup Run optional connector setup commands from contract.
|
|
105
112
|
write-summary Write normalized integration signal artifact JSON.
|
|
@@ -130,6 +137,18 @@ wait-connectors options:
|
|
|
130
137
|
--attempts <number> Maximum connection attempts per connector (default: 60)
|
|
131
138
|
--interval-ms <number> Delay between attempts in milliseconds (default: 1000)
|
|
132
139
|
|
|
140
|
+
setup-db-account environment:
|
|
141
|
+
DB_HOST, DB_PORT The database this job stands up.
|
|
142
|
+
CI_DB_ROOT_USER The administrative account of that database — the sidecar's
|
|
143
|
+
CI_DB_ROOT_PASSWORD root, under its OWN name. It is never taken from DB_USER:
|
|
144
|
+
DB_USER is who the job connects AS, and the two being one
|
|
145
|
+
value is the thing this command exists to end.
|
|
146
|
+
DB_USER The account every later step connects as. It must be the one
|
|
147
|
+
the repository declares in ${ENV_TEMPLATE_DIR}/<service>.env.
|
|
148
|
+
DB_PASSWORD The password that account is CREATED with and connects with.
|
|
149
|
+
A throwaway value of the job; the env template declares the
|
|
150
|
+
key, never the secret.
|
|
151
|
+
|
|
133
152
|
write-summary options:
|
|
134
153
|
--output <path> Summary artifact path. Default: <service-root>/ci/integration-signal.json
|
|
135
154
|
--gate-verdict <pass|fail> Report a failure this summary cannot see for itself -
|
|
@@ -257,6 +276,146 @@ function reportInstallContract(result) {
|
|
|
257
276
|
return false;
|
|
258
277
|
}
|
|
259
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Fail with the one shape `architecture-principles.md` §5 asks for, and the
|
|
281
|
+
* prefix every other step of this gate prints.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} subject what was checked
|
|
284
|
+
* @param {string} problem what was found and what was expected
|
|
285
|
+
* @param {string} fix the command or edit that satisfies it
|
|
286
|
+
*/
|
|
287
|
+
function failSetupDbAccount(subject, problem, fix) {
|
|
288
|
+
process.stderr.write(`[BizCiGate] FAIL setup-db-account — ${subject} — ${problem}\n`
|
|
289
|
+
+ ` Fix: ${fix}\n`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Create this service's database account, and grant it its own schemas.
|
|
295
|
+
*
|
|
296
|
+
* This is the ONE step of a biz pipeline that runs under the database root
|
|
297
|
+
* account, and the last one that does. Everything after it — `setup-db`, the
|
|
298
|
+
* integration tier, the cookbooks — connects as the service, which is what makes
|
|
299
|
+
* CI prove the migration set under the rights production actually has
|
|
300
|
+
* (`db-migrations-first-deploy` 001: "migrace nikdy pod rootem"). Until d.567
|
|
301
|
+
* seven pipelines set `DB_USER: "root"` and proved it under rights no production
|
|
302
|
+
* box grants.
|
|
303
|
+
*
|
|
304
|
+
* Three facts, three owners, none of them invented here:
|
|
305
|
+
* - the SCHEMA is `database.schema` of the integration contract, the same
|
|
306
|
+
* value `setup-db` builds;
|
|
307
|
+
* - the ACCOUNT is `DB_USER` of the repository's own env template — the
|
|
308
|
+
* declaration uniform row `D-DB-ACCOUNT` measures, read through the one
|
|
309
|
+
* function that knows where it lives;
|
|
310
|
+
* - the STATEMENTS are `utils/dbAccountGrants.js`, the same definition the
|
|
311
|
+
* production runbook installs from.
|
|
312
|
+
*
|
|
313
|
+
* The password is the job's own throwaway value (`DB_PASSWORD`), never the
|
|
314
|
+
* `CHANGE_ME` placeholder an env template carries: a template declares the key,
|
|
315
|
+
* not the secret.
|
|
316
|
+
*/
|
|
317
|
+
function runSetupDbAccount(options) {
|
|
318
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
319
|
+
const database = contractInfo.contract.database;
|
|
320
|
+
|
|
321
|
+
if (!database) {
|
|
322
|
+
// NOT APPLICABLE, not OK — the same distinction `setup-db` draws one
|
|
323
|
+
// function down, and for the same reason: a step that prints OK for work it
|
|
324
|
+
// never did is the false guarantee `automation-gates.md` §5 names.
|
|
325
|
+
process.stdout.write(`[BizCiGate] NOT APPLICABLE setup-db-account — ${contractInfo.contractPath} declares no `
|
|
326
|
+
+ '"database" block, so this service has no schema and needs no account. Nothing was done, and nothing '
|
|
327
|
+
+ 'was expected to be.\n');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const serviceRoot = contractInfo.serviceRoot;
|
|
332
|
+
const { relative, declared } = readDeclaredAccount(serviceRoot);
|
|
333
|
+
|
|
334
|
+
if (relative === null || declared === null) {
|
|
335
|
+
failSetupDbAccount(ACCOUNT_KEY,
|
|
336
|
+
`this repository declares a database, and ${relative === null
|
|
337
|
+
? `no env template of its own under ${ENV_TEMPLATE_DIR}/ declares ${ACCOUNT_KEY}`
|
|
338
|
+
: `${relative} declares no ${ACCOUNT_KEY}`} — the account is not this command's to invent`,
|
|
339
|
+
`declare ${ACCOUNT_KEY}=oagen_<service> in ${relative === null ? `${ENV_TEMPLATE_DIR}/<service>.env` : relative}`
|
|
340
|
+
+ '; uniform row D-DB-ACCOUNT measures the same key.');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const connecting = process.env.DB_USER;
|
|
344
|
+
if (connecting !== declared) {
|
|
345
|
+
failSetupDbAccount('DB_USER',
|
|
346
|
+
`this job connects as ${JSON.stringify(connecting ?? null)} and the repository declares `
|
|
347
|
+
+ `${JSON.stringify(declared)} in ${relative} — creating one account and using another would report `
|
|
348
|
+
+ 'a success nothing consumes, and fail two steps later as an access denial',
|
|
349
|
+
`set DB_USER: "${declared}" in the job's variables, the value ${relative} declares.`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const required = {
|
|
353
|
+
DB_HOST: process.env.DB_HOST,
|
|
354
|
+
CI_DB_ROOT_USER: process.env.CI_DB_ROOT_USER,
|
|
355
|
+
CI_DB_ROOT_PASSWORD: process.env.CI_DB_ROOT_PASSWORD,
|
|
356
|
+
DB_PASSWORD: process.env.DB_PASSWORD
|
|
357
|
+
};
|
|
358
|
+
const fixes = {
|
|
359
|
+
DB_HOST: 'set it in the job variables to the alias of the database service container.',
|
|
360
|
+
CI_DB_ROOT_USER: 'set it in the job variables to the administrative account of the database '
|
|
361
|
+
+ 'sidecar (root). It is named separately from DB_USER on purpose: DB_USER is who the job '
|
|
362
|
+
+ 'connects as afterwards.',
|
|
363
|
+
CI_DB_ROOT_PASSWORD: 'set it in the job variables to the sidecar\'s MARIADB_ROOT_PASSWORD. '
|
|
364
|
+
+ 'The sidecar is thrown away with the job, so the value is a throwaway of the job too.',
|
|
365
|
+
DB_PASSWORD: 'set it in the job variables to a throwaway password; the account is CREATED with '
|
|
366
|
+
+ 'it here and connects with it in every later step. The env template declares the key, never '
|
|
367
|
+
+ 'the secret.'
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
for (const [name, value] of Object.entries(required)) {
|
|
371
|
+
if (value === undefined || value === '') {
|
|
372
|
+
failSetupDbAccount(name, 'not set, and this step cannot run without it', fixes[name]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const connection = {
|
|
377
|
+
host: required.DB_HOST,
|
|
378
|
+
port: Number.parseInt(process.env.DB_PORT || '3306', 10),
|
|
379
|
+
user: required.CI_DB_ROOT_USER,
|
|
380
|
+
password: required.CI_DB_ROOT_PASSWORD,
|
|
381
|
+
requireTls: process.env.DB_REQUIRE_TLS === '1'
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Said before any statement runs, pass or fail — the way `verify-env-contract`
|
|
385
|
+
// prints its scope. A step that names nothing until it succeeds leaves the
|
|
386
|
+
// reader of a failed job log with a refused statement and no idea which
|
|
387
|
+
// account it was for.
|
|
388
|
+
process.stdout.write(`[BizCiGate] setup-db-account scope: '${declared}'@'${ACCOUNT_HOST}' on `
|
|
389
|
+
+ `${connection.host}:${connection.port}, schema \`${database.schema}\` — the account declared in `
|
|
390
|
+
+ `${relative}, created by ${JSON.stringify(connection.user)}\n`);
|
|
391
|
+
|
|
392
|
+
const statements = databaseAccountSql({
|
|
393
|
+
schema: database.schema,
|
|
394
|
+
account: declared,
|
|
395
|
+
password: required.DB_PASSWORD,
|
|
396
|
+
// The integration tier builds throwaway schemas beside the declared one
|
|
397
|
+
// (`utils/throwawaySchema.js`); production installs exactly one and gets no
|
|
398
|
+
// such grant. The difference is named here, at its one caller.
|
|
399
|
+
ciThrowaway: true
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
statements.forEach((sql, index) => {
|
|
403
|
+
const result = defaultExec({ connection, sql });
|
|
404
|
+
if (result.status !== 0) {
|
|
405
|
+
// The statement is identified by its ORDINAL, never quoted: the first one
|
|
406
|
+
// carries the account's password.
|
|
407
|
+
failSetupDbAccount(`statement ${index + 1} of ${statements.length}`,
|
|
408
|
+
`the database at ${connection.host}:${connection.port} refused it:\n ${result.stderr}`,
|
|
409
|
+
`check that ${JSON.stringify(connection.user)} is the administrative account of that database, `
|
|
410
|
+
+ 'and that a mariadb/mysql client is on PATH in this job image.');
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
process.stdout.write(`[BizCiGate] OK setup-db-account — '${declared}'@'${ACCOUNT_HOST}': `
|
|
415
|
+
+ `${statements.length} statement(s), granted \`${database.schema}\` and its throwaway namespace `
|
|
416
|
+
+ `\`${database.schema}_*\`. Every step after this one connects as that account.\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
260
419
|
/**
|
|
261
420
|
* Build the schema the contract declares. One implementation for every service:
|
|
262
421
|
* the repo declares WHAT, this decides HOW (docs/biz/00-model/uniformity-principle.md).
|
|
@@ -382,18 +541,6 @@ async function runVerifyContract(options) {
|
|
|
382
541
|
}
|
|
383
542
|
|
|
384
543
|
|
|
385
|
-
/**
|
|
386
|
-
* Running cookbooks needs `tests/cookbooks/`, `operations.json` and the handler
|
|
387
|
-
* modules — nothing else. This command used to resolve the service's own
|
|
388
|
-
* `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()` and pass
|
|
389
|
-
* `service.url` to the runner, which never read it (`this.serviceUrl` appears
|
|
390
|
-
* nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer returns that
|
|
391
|
-
* field either — the owner removed `port` and `url` from the service shape
|
|
392
|
-
* (docs/governance/confirmations/biz-service-port-url.md 001), so the value had
|
|
393
|
-
* been `undefined` for every service on the platform. What the load still did
|
|
394
|
-
* was make a readable config, an installed wrapper and a resolvable `${ENV}`
|
|
395
|
-
* placeholder preconditions of a cookbook run that depends on none of them.
|
|
396
|
-
*/
|
|
397
544
|
/**
|
|
398
545
|
* End the process with this code, once everything written has left the buffers.
|
|
399
546
|
*
|
|
@@ -434,6 +581,27 @@ function exitWhenFlushed(code) {
|
|
|
434
581
|
done();
|
|
435
582
|
}
|
|
436
583
|
|
|
584
|
+
/**
|
|
585
|
+
* What a cookbook run reads, and what it does NOT.
|
|
586
|
+
*
|
|
587
|
+
* It reads `tests/cookbooks/`, `config/service/operations.json` and the
|
|
588
|
+
* service's `package.json`, and it dispatches the service's own v3 handler
|
|
589
|
+
* modules IN THIS PROCESS. So it needs whatever those handlers need: a handler
|
|
590
|
+
* that reaches its schema opens the service's real pool here, which is the
|
|
591
|
+
* subject of `exitWhenFlushed` above. "Nothing else" is what this sentence used
|
|
592
|
+
* to say, and the paragraph below it said the opposite (d.523).
|
|
593
|
+
*
|
|
594
|
+
* What it does not do is load the service CONFIG. This command used to resolve
|
|
595
|
+
* the service's own `@onlineapps/service-wrapper`, run `ConfigLoader.loadAll()`
|
|
596
|
+
* and pass `service.url` to the runner, which never read it (`this.serviceUrl`
|
|
597
|
+
* appears nowhere in CookbookTestRunner). `ConfigLoader.loadAll` no longer
|
|
598
|
+
* returns that field either — the owner removed `port` and `url` from the
|
|
599
|
+
* service shape (docs/governance/confirmations/biz-service-port-url.md 001), so
|
|
600
|
+
* the value had been `undefined` for every service on the platform. What the
|
|
601
|
+
* load still did was make a readable config, an installed wrapper and a
|
|
602
|
+
* resolvable `${ENV}` placeholder preconditions of a cookbook run that depends
|
|
603
|
+
* on none of them.
|
|
604
|
+
*/
|
|
437
605
|
async function runRunPreValidation(options) {
|
|
438
606
|
const outcome = await runPreValidation({
|
|
439
607
|
serviceRoot: path.resolve(options.serviceRoot),
|
|
@@ -451,7 +619,11 @@ async function runRunPreValidation(options) {
|
|
|
451
619
|
|
|
452
620
|
if (!outcome.ok) {
|
|
453
621
|
for (const step of outcome.failedSteps) {
|
|
454
|
-
|
|
622
|
+
// The record names itself: `results.steps` carries two kinds and this
|
|
623
|
+
// line printed `step_id` for both, so a rejected cookbook read as
|
|
624
|
+
// `undefined` (d.523). The identity comes from the one owner of it,
|
|
625
|
+
// through `runPreValidation`'s outcome.
|
|
626
|
+
process.stderr.write(`[BizCiGate] FAIL run-prevalidation — ${describeRecordIdentity(step)}: ${step.error}\n`);
|
|
455
627
|
for (const validationError of step.validationErrors) {
|
|
456
628
|
process.stderr.write(`[BizCiGate] * ${validationError}\n`);
|
|
457
629
|
}
|
|
@@ -689,6 +861,10 @@ async function main() {
|
|
|
689
861
|
await runWaitConnectors(options);
|
|
690
862
|
return;
|
|
691
863
|
}
|
|
864
|
+
if (command === 'setup-db-account') {
|
|
865
|
+
runSetupDbAccount(options);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
692
868
|
if (command === 'setup-db') {
|
|
693
869
|
runSetupDb(options);
|
|
694
870
|
return;
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* * bare paths - the uniform sync of confirmation 001 §3.2. It writes the
|
|
12
12
|
* files of the manifest classes `identical` and `generated` from the very
|
|
13
13
|
* `from:` reference those rows already point the conformance CHECK at, and
|
|
14
|
-
* never touches a file of the `own` class. A row
|
|
15
|
-
*
|
|
14
|
+
* never touches a file of the `own` class. A row that names no `from:`
|
|
15
|
+
* reference is not a row of this run — it is a requirement about the
|
|
16
|
+
* repository, and `npx oa-validate` is what answers it (d.507).
|
|
16
17
|
* * `--new <name>` - the whole service tree, from the template that ships
|
|
17
18
|
* inside this package (001 §2). It is what `api/scripts/add-service.sh
|
|
18
19
|
* --scaffold` runs; that script keeps the platform facts (the registry
|
|
@@ -48,7 +49,7 @@ const {
|
|
|
48
49
|
SSOT_PIN,
|
|
49
50
|
TEMPLATE_ROOT
|
|
50
51
|
} = require('../sync/serviceTemplate');
|
|
51
|
-
const { planSync, uniformRows } = require('../sync/uniformFiles');
|
|
52
|
+
const { planSync, uniformRows, isSyncRow } = require('../sync/uniformFiles');
|
|
52
53
|
const { loadManifest: loadUniformManifest, DEFAULT_MANIFEST_PATH } = require('../manifest/loadManifest');
|
|
53
54
|
const {
|
|
54
55
|
applyUniformRegion,
|
|
@@ -107,10 +108,11 @@ Usage:
|
|
|
107
108
|
is: generated output, committed for reading (001 §5). --check is the gate.
|
|
108
109
|
|
|
109
110
|
With bare paths (or none, meaning all of them) the run rewrites the files the
|
|
110
|
-
manifest declares in the classes "identical" and "generated"
|
|
111
|
-
reference the conformance check reads. Files
|
|
112
|
-
tests/**, the content of docs/** - are never
|
|
113
|
-
|
|
111
|
+
manifest declares in the classes "identical" and "generated" that name a
|
|
112
|
+
"from" reference, from that same reference the conformance check reads. Files
|
|
113
|
+
of the "own" class - src/**, tests/**, the content of docs/** - are never
|
|
114
|
+
written. A row naming no reference is a requirement about the repository
|
|
115
|
+
rather than a file, and npx oa-validate <serviceRoot> is what answers it.
|
|
114
116
|
|
|
115
117
|
--new <name> create a service tree from the packaged template
|
|
116
118
|
--into <dir> where that tree is written; it must not exist yet
|
|
@@ -721,9 +723,22 @@ function runSync(options) {
|
|
|
721
723
|
// The positionals are judged FIRST, against the manifest, and before anything
|
|
722
724
|
// that touches the disk: a mistyped subcommand must be named as what it is,
|
|
723
725
|
// not reported as a missing workspace three steps later.
|
|
724
|
-
const
|
|
726
|
+
const rows = uniformRows(loadUniformManifest(DEFAULT_MANIFEST_PATH));
|
|
727
|
+
const declared = rows.filter(isSyncRow).map((row) => row.path);
|
|
725
728
|
for (const wanted of options.paths) {
|
|
726
729
|
if (declared.includes(wanted)) continue;
|
|
730
|
+
|
|
731
|
+
// A path the uniform DOES declare, as a requirement rather than as a file
|
|
732
|
+
// this run renders, is refused by what it is and not as a typo: the reader
|
|
733
|
+
// named a real row, and what they need is the run that answers it.
|
|
734
|
+
const requirement = rows.find((row) => row.path === wanted);
|
|
735
|
+
if (requirement !== undefined) {
|
|
736
|
+
throw new Error(`[oa-sync-template] "${wanted}" is declared by ${requirement.id}, which names no "from" `
|
|
737
|
+
+ 'reference - it is a requirement about the repository, not a file this run renders. '
|
|
738
|
+
+ 'Fix: check it with npx oa-validate <serviceRoot>; the paths this run writes are '
|
|
739
|
+
+ `(${declared.join(', ')}).`);
|
|
740
|
+
}
|
|
741
|
+
|
|
727
742
|
throw new Error(`[oa-sync-template] Unknown argument "${wanted}" - it is neither a subcommand this run `
|
|
728
743
|
+ `has (${SUBCOMMAND_NAMES.join(', ')}) nor a path the uniform declares (${declared.join(', ')}). `
|
|
729
744
|
+ 'Fix: name one of those, or leave the paths out to write them all.');
|
package/src/cli/oa-validate.js
CHANGED
|
@@ -42,6 +42,7 @@ const { discoverBearers } = require('../manifest/discovery');
|
|
|
42
42
|
const { collectRows } = require('../manifest/walk');
|
|
43
43
|
const { CHECK_REGISTRY } = require('../manifest/checks');
|
|
44
44
|
const { declaredCategory, readPackage } = require('../manifest/checks/libraryContext');
|
|
45
|
+
const { listEnvReads } = require('../utils/envReads');
|
|
45
46
|
|
|
46
47
|
/** Exit code for a run that could not start at all — distinct from a finding. */
|
|
47
48
|
const USAGE_EXIT = 2;
|
|
@@ -84,6 +85,7 @@ Usage:
|
|
|
84
85
|
oa-validate --workspace <root> [--json]
|
|
85
86
|
oa-validate --library <packageDir> [--workspace <root>] [--json]
|
|
86
87
|
oa-validate --library --all [--workspace <root>] [--json]
|
|
88
|
+
oa-validate --env-reads [<serviceRoot>]
|
|
87
89
|
|
|
88
90
|
Checks a repository against the uniform manifest shipped in
|
|
89
91
|
@onlineapps/conn-orch-validator. The manifest version IS this package's
|
|
@@ -120,6 +122,15 @@ Options:
|
|
|
120
122
|
→ the rows that need it are reported NOT RUN, never as
|
|
121
123
|
passing.
|
|
122
124
|
--json Print the run as JSON instead of the table.
|
|
125
|
+
--env-reads Print the environment names this service reads — one per
|
|
126
|
+
line, sorted, nothing else — and exit 0. No table, no
|
|
127
|
+
banner, no verdict file: the output is read by a deploy
|
|
128
|
+
gate (mapfile), so it combines with no other mode. The set
|
|
129
|
+
is the one row C-ENV-READS measures against: the contract's
|
|
130
|
+
env block, the \${VAR} placeholders of config/service/*.json,
|
|
131
|
+
and the endpoint variables of the required connectors. A
|
|
132
|
+
name only the source reads is NOT here — that is a blocking
|
|
133
|
+
finding of C-ENV-READS with its own fix.
|
|
123
134
|
--help Print this and exit 0.
|
|
124
135
|
|
|
125
136
|
Output:
|
|
@@ -136,7 +147,9 @@ Exit codes:
|
|
|
136
147
|
`;
|
|
137
148
|
|
|
138
149
|
function parseArgs(argv) {
|
|
139
|
-
const parsed = {
|
|
150
|
+
const parsed = {
|
|
151
|
+
serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false, envReads: false
|
|
152
|
+
};
|
|
140
153
|
const args = [...argv];
|
|
141
154
|
|
|
142
155
|
while (args.length > 0) {
|
|
@@ -149,6 +162,8 @@ function parseArgs(argv) {
|
|
|
149
162
|
parsed.library = true;
|
|
150
163
|
} else if (token === '--all') {
|
|
151
164
|
parsed.all = true;
|
|
165
|
+
} else if (token === '--env-reads') {
|
|
166
|
+
parsed.envReads = true;
|
|
152
167
|
} else if (token === '--workspace') {
|
|
153
168
|
const value = args.shift();
|
|
154
169
|
if (!value || value.startsWith('--')) {
|
|
@@ -166,6 +181,27 @@ function parseArgs(argv) {
|
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
|
|
184
|
+
// Not a verdict and not combinable with one: the gate reads stdout as a list
|
|
185
|
+
// of names, so a table, a JSON document or a NOT RUN banner printed beside
|
|
186
|
+
// them would reach it as environment variables. The option that WOULD be
|
|
187
|
+
// harmless — `--workspace` — is refused with the rest, because this answer
|
|
188
|
+
// comes from the service's own contract and a workspace root would suggest it
|
|
189
|
+
// does not (`automation-gates.md` §1 requirement 1).
|
|
190
|
+
if (parsed.envReads && !parsed.help) {
|
|
191
|
+
const combined = [
|
|
192
|
+
parsed.json ? '--json' : null,
|
|
193
|
+
parsed.library ? '--library' : null,
|
|
194
|
+
parsed.all ? '--all' : null,
|
|
195
|
+
parsed.workspace !== null ? '--workspace' : null
|
|
196
|
+
].filter((token) => token !== null);
|
|
197
|
+
|
|
198
|
+
if (combined.length > 0) {
|
|
199
|
+
throw new Error(`[oa-validate] --env-reads cannot be combined with ${combined.join(', ')} - it prints `
|
|
200
|
+
+ 'environment names for a machine to read, and every other mode prints a verdict into the same '
|
|
201
|
+
+ 'stdout. Fix: run oa-validate --env-reads [<serviceRoot>] on its own.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
if (parsed.all && !parsed.library) {
|
|
170
206
|
throw new Error('[oa-validate] Option --all needs --library - a service is checked one repository at a time. '
|
|
171
207
|
+ 'Fix: oa-validate --library --all');
|
|
@@ -403,6 +439,29 @@ function runLibrary(options) {
|
|
|
403
439
|
return blockingOf(report).length > 0 ? 1 : 0;
|
|
404
440
|
}
|
|
405
441
|
|
|
442
|
+
/**
|
|
443
|
+
* `--env-reads`: the question a deploy gate asks before it judges a `.env`.
|
|
444
|
+
*
|
|
445
|
+
* Nothing but the names reaches stdout, and nothing is written to the tree: the
|
|
446
|
+
* caller is `mapfile -t reads < <(oa-validate --env-reads "$repo")` in
|
|
447
|
+
* `api/scripts/validate-env.sh`, which took `KEY=CHANGE_ME` in a key the biz
|
|
448
|
+
* image never opens for a reason to refuse a deploy. The set, and why it is not
|
|
449
|
+
* a grep for `process.env`, belong to `utils/envReads.js`.
|
|
450
|
+
*
|
|
451
|
+
* An empty answer is an answer, and it exits 0: a contract that declares no env
|
|
452
|
+
* block, uses no placeholder and requires no connector reads nothing this
|
|
453
|
+
* package can see. Whether that is true of the repository is row `C-ENV-READS`'s
|
|
454
|
+
* question, not this one's.
|
|
455
|
+
*
|
|
456
|
+
* @param {object} options parsed arguments
|
|
457
|
+
* @returns {number} exit code
|
|
458
|
+
*/
|
|
459
|
+
function runEnvReads(options) {
|
|
460
|
+
const names = listEnvReads(options.serviceRoot === null ? process.cwd() : options.serviceRoot);
|
|
461
|
+
if (names.length > 0) process.stdout.write(`${names.join('\n')}\n`);
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
406
465
|
function main(argv) {
|
|
407
466
|
const options = parseArgs(argv);
|
|
408
467
|
|
|
@@ -411,6 +470,8 @@ function main(argv) {
|
|
|
411
470
|
return 0;
|
|
412
471
|
}
|
|
413
472
|
|
|
473
|
+
if (options.envReads) return runEnvReads(options);
|
|
474
|
+
|
|
414
475
|
if (options.library) return runLibrary(options);
|
|
415
476
|
|
|
416
477
|
const manifest = loadManifest(DEFAULT_MANIFEST_PATH);
|
|
@@ -433,7 +494,13 @@ function main(argv) {
|
|
|
433
494
|
}
|
|
434
495
|
const serviceRoot = canonicalRoot(given);
|
|
435
496
|
|
|
436
|
-
|
|
497
|
+
// The workspace this SERVICE lies in, not the one this package lies in
|
|
498
|
+
// (d.540): the copy a service runs is the one it installed, and an installed
|
|
499
|
+
// copy is part of no checkout — `oa-validate .` inside a complete workspace
|
|
500
|
+
// printed NOT RESOLVED and reported every workspace row NOT RUN. The root is
|
|
501
|
+
// the one the caller named, never `process.cwd()`
|
|
502
|
+
// (`workspaceRoot.js` § The service a run is ABOUT).
|
|
503
|
+
const workspaceRoot = resolveWorkspaceRoot({ explicit: options.workspace, serviceRoot });
|
|
437
504
|
const result = runManifest({ manifest, serviceRoot, workspaceRoot });
|
|
438
505
|
|
|
439
506
|
process.stdout.write(options.json ? renderJson(result) : renderReport(result));
|
|
@@ -465,6 +532,7 @@ if (require.main === module) {
|
|
|
465
532
|
module.exports = {
|
|
466
533
|
main,
|
|
467
534
|
parseArgs,
|
|
535
|
+
runEnvReads,
|
|
468
536
|
runLibrary,
|
|
469
537
|
checkOnePackage,
|
|
470
538
|
checkEveryPackage,
|
package/src/index.js
CHANGED
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
* @see /api/docs/architecture/validator.md (validator architecture)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const MockMQClient = require('./mocks/MockMQClient');
|
|
16
|
-
const MockRegistry = require('./mocks/MockRegistry');
|
|
17
|
-
const MockStorage = require('./mocks/MockStorage');
|
|
18
|
-
|
|
19
15
|
const CookbookTestUtils = require('./CookbookTestUtils');
|
|
20
16
|
const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
|
|
21
17
|
const ValidationProofGenerator = require('./validators/ValidationProofGenerator');
|
|
@@ -34,11 +30,16 @@ const ValidationOrchestrator = require('./ValidationOrchestrator');
|
|
|
34
30
|
|
|
35
31
|
const { createServiceReadinessTests } = require('./helpers/createServiceReadinessTests');
|
|
36
32
|
|
|
33
|
+
// No infrastructure doubles are published. `MockMQClient`, `MockRegistry` and
|
|
34
|
+
// `MockStorage` were the furniture of `mockInfrastructure`, retired in d.576:
|
|
35
|
+
// pre-validation dispatches a step in-process against the service's own v3
|
|
36
|
+
// handler, so there is no transport to stand in for, and the database a handler
|
|
37
|
+
// reaches is the real one. `MockRegistry` stays inside the package as the
|
|
38
|
+
// registry stand-in of `helpers/createServiceReadinessTests`, which is a
|
|
39
|
+
// different concern and keeps its own consumer; `MockMQClient` as the double of
|
|
40
|
+
// `tests/unit/RegistrationFlow.test.js`. `MockStorage` had neither, so d.583
|
|
41
|
+
// deleted the file as well as the export (`tests/unit/mockInfrastructureRetired.test.js`).
|
|
37
42
|
module.exports = {
|
|
38
|
-
get MockMQClient() { return MockMQClient; },
|
|
39
|
-
get MockRegistry() { return MockRegistry; },
|
|
40
|
-
get MockStorage() { return MockStorage; },
|
|
41
|
-
|
|
42
43
|
get CookbookTestUtils() { return CookbookTestUtils; },
|
|
43
44
|
get ServiceReadinessValidator() { return ServiceReadinessValidator; },
|
|
44
45
|
get CookbookTestRunner() { return CookbookTestRunner; },
|
|
@@ -82,11 +83,18 @@ module.exports = {
|
|
|
82
83
|
get parseHandlerRef() { return require('./utils/handlerRef').parseHandlerRef; },
|
|
83
84
|
get resolveHandlerModule() { return require('./utils/handlerRef').resolveHandlerModule; },
|
|
84
85
|
|
|
86
|
+
// The other half of what a service's integration harness needs to build its
|
|
87
|
+
// throwaway schema: which .sql files make up the set, and in which order.
|
|
88
|
+
// `createThrowawaySchema` above resolves the plan itself; a harness that
|
|
89
|
+
// builds its own schema step by step reads the plan directly, and had to
|
|
90
|
+
// reach into `src/utils/setupDatabase` to get it — a path inside the package
|
|
91
|
+
// is not a contract, and every move of `src/utils/` would break a consumer
|
|
92
|
+
// that never agreed to one. Exported as the identical function, not a
|
|
93
|
+
// wrapper: one definition, one behaviour (`change-discipline.md` § One rail
|
|
94
|
+
// per concern).
|
|
95
|
+
get resolveMigrationPlan() { return require('./utils/setupDatabase').resolveMigrationPlan; },
|
|
96
|
+
|
|
85
97
|
get createServiceReadinessTests() { return createServiceReadinessTests; },
|
|
86
98
|
get BizCiGateContract() { return BizCiGateContract; },
|
|
87
|
-
get IntegrationRun() { return require('./utils/integrationRun'); }
|
|
88
|
-
|
|
89
|
-
createMockMQ: () => new MockMQClient(),
|
|
90
|
-
createMockRegistry: () => new MockRegistry(),
|
|
91
|
-
createMockStorage: () => new MockStorage()
|
|
99
|
+
get IntegrationRun() { return require('./utils/integrationRun'); }
|
|
92
100
|
};
|
|
@@ -30,12 +30,22 @@
|
|
|
30
30
|
* biz repositories: 47 `@see` lines under their scripts directories, of which 4
|
|
31
31
|
* open with the `api/` prefix.
|
|
32
32
|
*
|
|
33
|
-
* Two scopes,
|
|
34
|
-
* `scripts/`. S009
|
|
35
|
-
* nothing else
|
|
36
|
-
* mechanism keeping it true (`doc-code-binding.md`
|
|
37
|
-
* a CODE line is a fixture assertion whose target
|
|
38
|
-
* which therefore cannot rot.
|
|
33
|
+
* Two scopes, three rules. S001-S008 read the HEADER of every script under
|
|
34
|
+
* `scripts/`. S009 and S010 read the tests, and they read opposite halves of
|
|
35
|
+
* them. S009 reads the COMMENTS and nothing else: a `<path>.<ext>:<line>`
|
|
36
|
+
* citation in prose has no mechanism keeping it true (`doc-code-binding.md`
|
|
37
|
+
* §1), while the same shape on a CODE line is a fixture assertion whose target
|
|
38
|
+
* the test creates itself and which therefore cannot rot. S010 reads the CODE
|
|
39
|
+
* lines and nothing else: an inverted command is exempt from `errexit`, so an
|
|
40
|
+
* assertion written that way asserts nothing unless it happens to be the last
|
|
41
|
+
* command of the body.
|
|
42
|
+
*
|
|
43
|
+
* The test scope is `tests/`, not the one directory S009 was born in. A rule
|
|
44
|
+
* that reads `tests/scripts/` and not `tests/scripts-docker/` beside it has a
|
|
45
|
+
* hole nobody can see: the run reports `0 finding(s)` and never says which
|
|
46
|
+
* files it did not open. Widened in d.482 (2026-09-15) together with S010, over
|
|
47
|
+
* a scope measured clean for both rules first — no baseline
|
|
48
|
+
* (`automation-gates.md` §3).
|
|
39
49
|
*
|
|
40
50
|
* @see api/docs/standards/SCRIPTS-STANDARD.md
|
|
41
51
|
* @see api/docs/governance/confirmations/biz-service-manifest.md
|
|
@@ -54,8 +64,8 @@ const SHELL_VOCAB = new Set(['bash>=4', 'bash>=3.2', 'sh', 'node>=18', 'node>=24
|
|
|
54
64
|
/** The directory whose scripts carry the header. */
|
|
55
65
|
const SCRIPT_SCOPE = 'scripts';
|
|
56
66
|
|
|
57
|
-
/** The directory whose
|
|
58
|
-
const CITATION_SCOPE = 'tests
|
|
67
|
+
/** The directory whose tests S009 and S010 read, each one half of them. */
|
|
68
|
+
const CITATION_SCOPE = 'tests';
|
|
59
69
|
|
|
60
70
|
/**
|
|
61
71
|
* The directories whose files are libraries rather than entry points.
|
|
@@ -111,6 +121,27 @@ const LINE_CITATION = /[A-Za-z0-9_/.-]+\.(?:js|mjs|cjs|ts|mts|sh|bash|bats|yml|j
|
|
|
111
121
|
/** A comment line of a shell-family test. */
|
|
112
122
|
const COMMENT_LINE = /^\s*#/;
|
|
113
123
|
|
|
124
|
+
/**
|
|
125
|
+
* An assertion written as an inverted command, in the two shapes bats carries.
|
|
126
|
+
*
|
|
127
|
+
* Measured on bats 1.13.0: under `set -e` a command whose exit code is inverted
|
|
128
|
+
* is exempt from errexit, so `! cmd` in the middle of a test body fails nothing
|
|
129
|
+
* — the test stays green whatever `cmd` returns. Only the LAST command of the
|
|
130
|
+
* body decides the verdict, which is why the defect survives review: the line
|
|
131
|
+
* reads like an assertion and is one exactly when it happens to sit last.
|
|
132
|
+
*
|
|
133
|
+
* The rule does not try to tell the last command from the others. It reports
|
|
134
|
+
* every occurrence, because the position is a property of the file's current
|
|
135
|
+
* shape rather than of the assertion's intent: a line inserted below turns a
|
|
136
|
+
* working assertion into an inert one with no edit to the assertion itself.
|
|
137
|
+
* INFRA rewrote all 32 occurrences this platform carried before the rule landed
|
|
138
|
+
* (`572488c4`, `6100477e`, `d212d3c6`), so the stricter reading costs nothing.
|
|
139
|
+
*
|
|
140
|
+
* `if ! cmd; then … fi` is NOT this defect and matches neither pattern: there
|
|
141
|
+
* the `if` consumes the exit code on purpose, which is the shape the fix uses.
|
|
142
|
+
*/
|
|
143
|
+
const INERT_ASSERTION = Object.freeze([/^\s*!\s/, /\|\|\s*!\s/]);
|
|
144
|
+
|
|
114
145
|
/** The prefix a citation of the api checkout opens with, as every document writes it. */
|
|
115
146
|
const API_PREFIX = 'api/';
|
|
116
147
|
|
|
@@ -265,22 +296,38 @@ function lintScripts({ root, apiRoot = null }) {
|
|
|
265
296
|
);
|
|
266
297
|
}
|
|
267
298
|
|
|
268
|
-
// S009
|
|
269
|
-
//
|
|
299
|
+
// S009 on the comment lines, S010 on the code lines. One pass, because they
|
|
300
|
+
// partition the same file: a line is prose or it is a command, never both.
|
|
270
301
|
for (const relative of citationScanned) {
|
|
271
302
|
const lines = fs.readFileSync(path.join(root, ...relative.split('/')), 'utf8').split('\n');
|
|
272
303
|
for (let n = 0; n < lines.length; n += 1) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (
|
|
304
|
+
// S009: a comment cites a symbol or a literal string, and a covered
|
|
305
|
+
// defect cites the commit hash — never a line number.
|
|
306
|
+
if (COMMENT_LINE.test(lines[n])) {
|
|
307
|
+
const hit = lines[n].match(LINE_CITATION);
|
|
308
|
+
if (hit === null) continue;
|
|
309
|
+
findings.push({
|
|
310
|
+
id: 'S009',
|
|
311
|
+
file: relative,
|
|
312
|
+
line: n + 1,
|
|
313
|
+
message: `Comment cites a line number - "${hit[0]}". A line number has no mechanism keeping it true: `
|
|
314
|
+
+ 'the file moves and the number stays (doc-code-binding.md §1). Fix: cite a symbol or a literal '
|
|
315
|
+
+ 'string; a covered defect cites the commit hash that introduced or repaired it '
|
|
316
|
+
+ "(git log -S'<string>' --format=%h -- <file>) - DOC-STANDARD rule 11."
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// S010: an assertion whose exit code is inverted is exempt from errexit.
|
|
322
|
+
if (!INERT_ASSERTION.some((shape) => shape.test(lines[n]))) continue;
|
|
276
323
|
findings.push({
|
|
277
|
-
id: '
|
|
324
|
+
id: 'S010',
|
|
278
325
|
file: relative,
|
|
279
326
|
line: n + 1,
|
|
280
|
-
message: `
|
|
281
|
-
+ '
|
|
282
|
-
+ '
|
|
283
|
-
+
|
|
327
|
+
message: `Inert assertion - "${lines[n].trim()}". Under set -e a command with an inverted exit code `
|
|
328
|
+
+ 'is exempt from errexit, so it fails nothing unless it is the last command of the test body. '
|
|
329
|
+
+ 'Fix: if <what must not hold>; then echo "[ctx] problem - found: …" >&2; return 1; fi '
|
|
330
|
+
+ '— SCRIPTS-STANDARD.md § Enforcement'
|
|
284
331
|
});
|
|
285
332
|
}
|
|
286
333
|
}
|