@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one definition of a biz service's database account.
|
|
5
|
+
*
|
|
6
|
+
* Owner decision `db-accounts-per-service` 001: every service reaches its schema
|
|
7
|
+
* as ITSELF, with grants on its own schemas, and root stops being an operational
|
|
8
|
+
* identity. `db-migrations-first-deploy` 001 says what that is worth — "migrace
|
|
9
|
+
* nikdy pod rootem" (:32) — and, in the same breath, "žádný nový generátor SQL …
|
|
10
|
+
* žádná druhá kolej" (:33). So these statements exist ONCE: the production
|
|
11
|
+
* runbook (`api/docs/setup/INSTALL.md` § the account and its grants) and
|
|
12
|
+
* `biz-ci-gate setup-db-account` are two CALLERS of this function, not two
|
|
13
|
+
* copies of the same SQL.
|
|
14
|
+
*
|
|
15
|
+
* `createDatabaseSql` in `setupDatabase.js` stays where it is and is not
|
|
16
|
+
* re-exported here: creating the SCHEMA and creating the ACCOUNT are two
|
|
17
|
+
* concerns with two moments (the account exists before the schema in CI, and is
|
|
18
|
+
* created by the operator before the installer runs in production).
|
|
19
|
+
*
|
|
20
|
+
* ## The one difference between CI and production
|
|
21
|
+
*
|
|
22
|
+
* An integration suite builds THROWAWAY schemas beside the declared one —
|
|
23
|
+
* `src/utils/throwawaySchema.js`, used today as `oagen_emailer_fixture_<ms>_<pid>`
|
|
24
|
+
* (`api_biz/emailer/tests/integration/fixtureSchema.js`) and
|
|
25
|
+
* `oagen_meta_platformseedtest` (`api_biz/meta`). A grant on `oagen_emailer`
|
|
26
|
+
* alone refuses every one of them, which is why `ciThrowaway: true` adds a
|
|
27
|
+
* second grant over the service's own namespace.
|
|
28
|
+
*
|
|
29
|
+
* It does NOT widen the account's reach to another service: the pattern is
|
|
30
|
+
* anchored on this schema's name, and the `_` is ESCAPED. Unescaped, `_` is a
|
|
31
|
+
* single-character LIKE wildcard, so `oagen_meta_%` would also match
|
|
32
|
+
* `oagen_metadata` — a neighbouring service handed over whole. The backslash is
|
|
33
|
+
* what keeps the grant inside one service, and it is the difference
|
|
34
|
+
* `tests/unit/dbAccountGrants.test.js` measures.
|
|
35
|
+
*
|
|
36
|
+
* Production never gets that grant: it installs one schema, and a wildcard there
|
|
37
|
+
* would be a standing privilege nobody asked for.
|
|
38
|
+
*
|
|
39
|
+
* ## What is deliberately NOT here
|
|
40
|
+
*
|
|
41
|
+
* `GRANT SELECT ON information_schema.*` — the privilege
|
|
42
|
+
* `setupDatabase.assertTargetIsEmpty` names in its own error message. The server
|
|
43
|
+
* refuses to grant it at all (measured 2026-09-16, dev `gen_mariadb10.5`,
|
|
44
|
+
* MariaDB 10.5.17: `ERROR 1044 (42000): Access denied for user 'root'@'localhost'
|
|
45
|
+
* to database 'information_schema'`). Every account reads that catalogue
|
|
46
|
+
* already, filtered to the schemas it is granted — which is precisely the answer
|
|
47
|
+
* the emptiness probe needs.
|
|
48
|
+
*
|
|
49
|
+
* @see api/docs/governance/confirmations/db-accounts-per-service.md
|
|
50
|
+
* @see api/docs/governance/confirmations/db-migrations-first-deploy.md
|
|
51
|
+
* @see src/utils/setupDatabase.js
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The host part of every account. One value, because a second spelling is a
|
|
56
|
+
* second account: `'x'@'%'` and `'x'@'localhost'` are different rows in
|
|
57
|
+
* `mysql.user`, with different grants, and an installer that picked one while
|
|
58
|
+
* the gate created the other would report an account the service cannot use.
|
|
59
|
+
*/
|
|
60
|
+
const ACCOUNT_HOST = '%';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Identifiers and passwords reach the server as TEXT, so they are checked rather
|
|
64
|
+
* than escaped: an identifier in this platform is `oagen_<shortname>`, and
|
|
65
|
+
* anything carrying a quote, a backslash or a backtick is not one.
|
|
66
|
+
*
|
|
67
|
+
* Refusing is the whole of the answer (`architecture-principles.md` §3): a value
|
|
68
|
+
* that needs escaping here came from somewhere it should not have.
|
|
69
|
+
*/
|
|
70
|
+
const FORBIDDEN_IN_VALUE = /['"`\\;\n\r]/;
|
|
71
|
+
|
|
72
|
+
function requireValue(value, name, fix) {
|
|
73
|
+
if (typeof value !== 'string' || value === '') {
|
|
74
|
+
throw new Error(`[DbAccountGrants] Missing ${name} - Expected a non-empty value.\n`
|
|
75
|
+
+ ` Fix: ${fix}`);
|
|
76
|
+
}
|
|
77
|
+
if (FORBIDDEN_IN_VALUE.test(value)) {
|
|
78
|
+
throw new Error(`[DbAccountGrants] ${name} carries a character these statements cannot contain: `
|
|
79
|
+
+ `${JSON.stringify(value)}.\n`
|
|
80
|
+
+ ' The statements are text handed to the SQL client, so a quote, backslash, backtick, '
|
|
81
|
+
+ 'semicolon or newline would end one statement and start another.\n'
|
|
82
|
+
+ ` Fix: ${fix}`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every statement that creates this service's account and grants it what it
|
|
89
|
+
* needs, in the order they must run.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} options
|
|
92
|
+
* @param {string} options.schema the schema the service declares
|
|
93
|
+
* (`database.schema` of `config/service/integration-contract.json`)
|
|
94
|
+
* @param {string} options.account the account the service declares
|
|
95
|
+
* (`DB_USER` of `config/env-templates/<service>.env`)
|
|
96
|
+
* @param {string} options.password the password the account is created with
|
|
97
|
+
* @param {boolean} [options.ciThrowaway] also grant the throwaway namespace
|
|
98
|
+
* `<schema>\_%`, which only an integration tier builds into
|
|
99
|
+
* @returns {string[]} the statements, in order
|
|
100
|
+
*/
|
|
101
|
+
function databaseAccountSql({ schema, account, password, ciThrowaway = false } = {}) {
|
|
102
|
+
requireValue(schema, 'schema', 'pass database.schema from config/service/integration-contract.json.');
|
|
103
|
+
requireValue(account, 'account', 'pass DB_USER from this service\'s config/env-templates/<service>.env.');
|
|
104
|
+
requireValue(password, 'password', 'pass the password the account is created with; an account created '
|
|
105
|
+
+ 'with an empty password is one anybody on the network can use.');
|
|
106
|
+
|
|
107
|
+
const identity = `'${account}'@'${ACCOUNT_HOST}'`;
|
|
108
|
+
|
|
109
|
+
const statements = [
|
|
110
|
+
`CREATE USER IF NOT EXISTS ${identity} IDENTIFIED BY '${password}';`,
|
|
111
|
+
`GRANT ALL PRIVILEGES ON \`${schema}\`.* TO ${identity};`
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
if (ciThrowaway === true) {
|
|
115
|
+
// The backslash escapes `_` so the pattern stays inside this service; see
|
|
116
|
+
// the head of this file for the neighbour it would otherwise reach.
|
|
117
|
+
statements.push(`GRANT ALL PRIVILEGES ON \`${schema}\\_%\`.* TO ${identity};`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Last, always: a grant is not live for a connection opened before it.
|
|
121
|
+
statements.push('FLUSH PRIVILEGES;');
|
|
122
|
+
|
|
123
|
+
return statements;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { databaseAccountSql, ACCOUNT_HOST };
|
package/src/utils/envContract.js
CHANGED
|
@@ -378,17 +378,26 @@ function assertScannableServiceRoot(serviceRoot) {
|
|
|
378
378
|
}
|
|
379
379
|
|
|
380
380
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* The names this service's environment is ALLOWED to be judged by: everything
|
|
382
|
+
* the declaration names, plus everything M1/M2 already cover.
|
|
383
|
+
*
|
|
384
|
+
* It is the set `verifyEnvCompleteness` measures the repository against, lifted
|
|
385
|
+
* out of it so a second caller cannot grow a second definition of it (d.533c;
|
|
386
|
+
* `change-discipline.md` § One rail per concern). The completeness check below
|
|
387
|
+
* asks the same question from the other side — which read is NOT in this set —
|
|
388
|
+
* and `oa-validate --env-reads` prints the set itself, for the deploy gate that
|
|
389
|
+
* must not demand a value for a name this service never reads.
|
|
390
|
+
*
|
|
391
|
+
* Sorted with the default comparator rather than `localeCompare`: the order is
|
|
392
|
+
* read by a machine, and a collation that depends on the host's locale data is
|
|
393
|
+
* the ambient state `automation-gates.md` §1.1 forbids.
|
|
383
394
|
*
|
|
384
395
|
* @param {object} args
|
|
385
396
|
* @param {string} args.serviceRoot
|
|
386
397
|
* @param {object} args.contract normalized integration contract
|
|
387
|
-
* @returns {{
|
|
388
|
-
* coveredCount: number, readCount: number, filesScanned: number,
|
|
389
|
-
* dynamicSites: Array<{file: string, line: number}>}}
|
|
398
|
+
* @returns {{names: string[], coverage: Map<string,string>, declared: Set<string>}}
|
|
390
399
|
*/
|
|
391
|
-
function
|
|
400
|
+
function collectDeclaredEnvNames({ serviceRoot, contract }) {
|
|
392
401
|
assertScannableServiceRoot(serviceRoot);
|
|
393
402
|
assertNormalizedContract(contract);
|
|
394
403
|
|
|
@@ -409,6 +418,26 @@ function verifyEnvCompleteness({ serviceRoot, contract }) {
|
|
|
409
418
|
for (const item of declaration?.[listKey] ?? []) declared.add(item.name);
|
|
410
419
|
}
|
|
411
420
|
|
|
421
|
+
const names = new Set(coverage.keys());
|
|
422
|
+
for (const name of declared) names.add(name);
|
|
423
|
+
|
|
424
|
+
return { names: [...names].sort(), coverage, declared };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* COMPLETENESS: every environment name the repository visibly reads is either
|
|
429
|
+
* declared in the env block or covered by M1/M2.
|
|
430
|
+
*
|
|
431
|
+
* @param {object} args
|
|
432
|
+
* @param {string} args.serviceRoot
|
|
433
|
+
* @param {object} args.contract normalized integration contract
|
|
434
|
+
* @returns {{ok: boolean, violations: Array<{name: string, sources: string[]}>, declaredCount: number,
|
|
435
|
+
* coveredCount: number, readCount: number, filesScanned: number,
|
|
436
|
+
* dynamicSites: Array<{file: string, line: number}>}}
|
|
437
|
+
*/
|
|
438
|
+
function verifyEnvCompleteness({ serviceRoot, contract }) {
|
|
439
|
+
const { coverage, declared } = collectDeclaredEnvNames({ serviceRoot, contract });
|
|
440
|
+
|
|
412
441
|
const { reads, dynamicSites, filesScanned } = collectEnvReads({ serviceRoot });
|
|
413
442
|
|
|
414
443
|
const violations = [];
|
|
@@ -467,6 +496,7 @@ module.exports = {
|
|
|
467
496
|
ENV_SCAN_BLIND_SPOTS,
|
|
468
497
|
normalizeEnvDeclaration,
|
|
469
498
|
collectEnvCoverage,
|
|
499
|
+
collectDeclaredEnvNames,
|
|
470
500
|
collectEnvReads,
|
|
471
501
|
verifyEnvCompleteness,
|
|
472
502
|
verifyEnvPresence
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which environment names a service's image reads — asked by a deploy gate,
|
|
5
|
+
* answered by the contract (d.533c).
|
|
6
|
+
*
|
|
7
|
+
* ## The defect this ends
|
|
8
|
+
*
|
|
9
|
+
* `api/scripts/validate-env.sh` refuses a deploy when a key in the target's
|
|
10
|
+
* `.env` still says `CHANGE_ME`. Pointed at a biz service it refused on
|
|
11
|
+
* `JWT_SECRET`, a key the gateway reads and no biz image ever opens: the gate
|
|
12
|
+
* was judging the platform's whole key set against one service. The fix is not
|
|
13
|
+
* a list of exceptions in bash — it is the service saying what it reads.
|
|
14
|
+
*
|
|
15
|
+
* ## Why not a grep for `process.env`
|
|
16
|
+
*
|
|
17
|
+
* Because most of the platform's environment is read inside installed
|
|
18
|
+
* libraries: `@onlineapps/service-wrapper` reads `SECRETS_MASTER_KEYS`,
|
|
19
|
+
* `@onlineapps/conn-base-cache` reads `REDIS_URL`. A grep over `src/` sees none
|
|
20
|
+
* of them and would answer a set too small — which in a gate means a key
|
|
21
|
+
* silently unchecked. That blind spot is stated, not discovered:
|
|
22
|
+
* `utils/envContract.js` § Measurability boundary.
|
|
23
|
+
*
|
|
24
|
+
* ## The set is the uniform's, not a second definition
|
|
25
|
+
*
|
|
26
|
+
* The answer is `collectDeclaredEnvNames` — the contract's `env` block, the
|
|
27
|
+
* `${VAR}` placeholders of `config/service/*.json`, and the endpoint variables
|
|
28
|
+
* of the connectors the contract declares required. That is exactly the set row
|
|
29
|
+
* `C-ENV-READS` of the biz-service uniform measures the repository against, so
|
|
30
|
+
* the two can never disagree about what a service reads
|
|
31
|
+
* (`change-discipline.md` § One rail per concern).
|
|
32
|
+
*
|
|
33
|
+
* The consequence is deliberate: a name the SOURCE reads and the contract does
|
|
34
|
+
* not declare is absent from this answer. It is a blocking finding of
|
|
35
|
+
* `C-ENV-READS`, with its own fix, and a gate must not demand a value for a key
|
|
36
|
+
* nobody owns.
|
|
37
|
+
*
|
|
38
|
+
* The messages carry the context `[oa-validate]` because the subcommand is this
|
|
39
|
+
* module's only caller and the message goes to a bash gate reading stderr; there
|
|
40
|
+
* is no wrapper re-wording them, which is how they stay the exact sentence the
|
|
41
|
+
* test asserts (`automation-gates.md` §2).
|
|
42
|
+
*
|
|
43
|
+
* @see api/docs/biz/70-contracts/env-contract.md
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
const { loadAndValidateIntegrationContract } = require('./bizCiGateContract');
|
|
50
|
+
const { collectDeclaredEnvNames } = require('./envContract');
|
|
51
|
+
|
|
52
|
+
/** Where every biz service declares what it integrates with. */
|
|
53
|
+
const CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every environment name the service at `serviceRoot` reads, as the uniform
|
|
57
|
+
* measures it.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} serviceRoot repository root of the service; the caller
|
|
60
|
+
* resolves its own default, so an empty value is refused rather than silently
|
|
61
|
+
* answered about `process.cwd()`
|
|
62
|
+
* @returns {string[]} the names, sorted, without duplicates
|
|
63
|
+
* @throws {Error} when the root or the contract cannot be read — a gate that
|
|
64
|
+
* cannot get the set must stop, never continue with an empty one
|
|
65
|
+
*/
|
|
66
|
+
function listEnvReads(serviceRoot) {
|
|
67
|
+
if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
|
|
68
|
+
throw new Error('[oa-validate] No service root to answer about - --env-reads was given '
|
|
69
|
+
+ `${typeof serviceRoot === 'string' ? 'an empty path' : typeof serviceRoot}. `
|
|
70
|
+
+ 'Fix: oa-validate --env-reads <serviceRoot>, or run it inside the service repository.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const root = path.resolve(serviceRoot);
|
|
74
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
75
|
+
throw new Error(`[oa-validate] Service root not found - ${root}. `
|
|
76
|
+
+ 'Fix: pass an existing repository directory, or run the command inside the service repository.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contractPath = path.join(root, CONTRACT_RELATIVE_PATH);
|
|
80
|
+
if (!fs.existsSync(contractPath)) {
|
|
81
|
+
throw new Error(`[oa-validate] No environment contract - ${contractPath} does not exist, so nothing `
|
|
82
|
+
+ 'declares which environment names this service reads and the answer would be an empty set. '
|
|
83
|
+
+ 'Fix: add config/service/integration-contract.json with requiredConnectors and, where the '
|
|
84
|
+
+ 'service reads a name no placeholder and no connector covers, an "env" block.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let contract;
|
|
88
|
+
try {
|
|
89
|
+
contract = loadAndValidateIntegrationContract(root).contract;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`[oa-validate] Unusable environment contract - ${contractPath}: `
|
|
92
|
+
+ `${String(error.message).split('\n')[0].trim()} `
|
|
93
|
+
+ 'Fix: correct the contract, then run npx oa-validate to see the row that judges it.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return collectDeclaredEnvNames({ serviceRoot: root, contract }).names;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
CONTRACT_RELATIVE_PATH,
|
|
101
|
+
listEnvReads
|
|
102
|
+
};
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* applies — in both directions (§7)
|
|
21
21
|
* SQL_HEADERS every live SQL file declares its dataset class and safety, with
|
|
22
22
|
* VALUES from the contract's closed vocabularies and not merely the
|
|
23
|
-
* four keys (§4)
|
|
23
|
+
* four keys (§4). "Live" is what the installer applies: the
|
|
24
|
+
* migrations tree outside history, AND the seeds the integration
|
|
25
|
+
* contract declares, wherever they sit
|
|
26
|
+
* (`installation-sql-contract-scope` 003)
|
|
24
27
|
*
|
|
25
28
|
* Rules: api/docs/standards/repository-installation-sql-contract.md §2-§5, §7
|
|
26
29
|
* @see api/docs/governance/confirmations/installation-sql-contract-scope.md
|
|
@@ -127,8 +130,22 @@ function listSqlFiles(absoluteDir) {
|
|
|
127
130
|
* package directories alone: the root `migrations/*.sql` set IS the live
|
|
128
131
|
* migration set of four services, and until this loop reached it those files
|
|
129
132
|
* carried no header requirement at all while the gate reported PASS.
|
|
133
|
+
*
|
|
134
|
+
* `migrations/**` is not the whole scope either. What the contract covers is
|
|
135
|
+
* what the INSTALLER APPLIES, whatever directory it sits in
|
|
136
|
+
* (`installation-sql-contract-scope` 003: "Ano, vše co instalátor aplikuje"),
|
|
137
|
+
* and what the installer applies beyond the migration set is exactly the
|
|
138
|
+
* `database.seeds` list of the integration contract — read here from that one
|
|
139
|
+
* declaration rather than from a second list of directories, which would rot
|
|
140
|
+
* the moment a service put a seed somewhere new (`doc-code-binding.md` §1).
|
|
141
|
+
* Measured 2026-09-16: converter declares two seeds under `scripts/seed/` and
|
|
142
|
+
* ingest one, and the header gate had never looked at any of them.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} serviceRoot repository root to walk
|
|
145
|
+
* @param {string[]} seeds repo-relative seed paths the contract declares, which
|
|
146
|
+
* exist; one already inside `migrations/**` is not listed twice
|
|
130
147
|
*/
|
|
131
|
-
function listLiveSqlFiles(serviceRoot) {
|
|
148
|
+
function listLiveSqlFiles(serviceRoot, seeds = []) {
|
|
132
149
|
const found = [];
|
|
133
150
|
|
|
134
151
|
const walk = (relativeDir) => {
|
|
@@ -149,6 +166,11 @@ function listLiveSqlFiles(serviceRoot) {
|
|
|
149
166
|
};
|
|
150
167
|
|
|
151
168
|
walk(MIGRATIONS_ROOT);
|
|
169
|
+
|
|
170
|
+
for (const seed of seeds) {
|
|
171
|
+
if (!found.includes(seed)) found.push(seed);
|
|
172
|
+
}
|
|
173
|
+
|
|
152
174
|
return found;
|
|
153
175
|
}
|
|
154
176
|
|
|
@@ -285,7 +307,26 @@ function checkManifestCompleteness(serviceRoot, manifestFile, add) {
|
|
|
285
307
|
}
|
|
286
308
|
}
|
|
287
309
|
|
|
288
|
-
|
|
310
|
+
/**
|
|
311
|
+
* The seeds the contract declares AND the repository carries. A declared file
|
|
312
|
+
* that is not there is reported rather than read: the installer would stop on
|
|
313
|
+
* it, so the gate says so with the same words instead of throwing ENOENT out of
|
|
314
|
+
* a header check (`automation-gates.md` §1 requirement 4).
|
|
315
|
+
*/
|
|
316
|
+
function existingSeeds(serviceRoot, database, add) {
|
|
317
|
+
const declared = Array.isArray(database?.seeds) ? database.seeds : [];
|
|
318
|
+
|
|
319
|
+
return declared.filter((relativeFile) => {
|
|
320
|
+
if (existsExactly(serviceRoot, relativeFile.split('/'))) return true;
|
|
321
|
+
|
|
322
|
+
add('SQL_PACKAGE', 'Declared seed does not exist - config/service/integration-contract.json '
|
|
323
|
+
+ `declares "${relativeFile}" in database.seeds and the repository does not carry it. `
|
|
324
|
+
+ 'Fix: add the file, or remove the declaration — the installer applies exactly what is declared.');
|
|
325
|
+
return false;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function checkSqlPackage(serviceRoot, database, add) {
|
|
289
330
|
for (const relativeDir of SQL_DIRECTORIES) {
|
|
290
331
|
const absoluteDir = path.join(serviceRoot, relativeDir);
|
|
291
332
|
|
|
@@ -303,7 +344,7 @@ function checkSqlPackage(serviceRoot, add) {
|
|
|
303
344
|
}
|
|
304
345
|
}
|
|
305
346
|
|
|
306
|
-
const liveFiles = listLiveSqlFiles(serviceRoot);
|
|
347
|
+
const liveFiles = listLiveSqlFiles(serviceRoot, existingSeeds(serviceRoot, database, add));
|
|
307
348
|
|
|
308
349
|
for (const relativeFile of liveFiles) {
|
|
309
350
|
checkSqlHeaders(serviceRoot, relativeFile, add);
|
|
@@ -374,7 +415,7 @@ function verifyInstallContract(serviceRoot, database) {
|
|
|
374
415
|
|
|
375
416
|
const databaseChecked = database !== null && database !== undefined;
|
|
376
417
|
if (databaseChecked) {
|
|
377
|
-
checkSqlPackage(serviceRoot, add);
|
|
418
|
+
checkSqlPackage(serviceRoot, database, add);
|
|
378
419
|
}
|
|
379
420
|
|
|
380
421
|
return {
|
package/src/utils/libCompat.js
CHANGED
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
* to "not gated" (principle 3), and it would let a typo or a retired package
|
|
24
24
|
* name travel into an image with the gate reporting OK (measured 2026-09-14).
|
|
25
25
|
*
|
|
26
|
+
* The narrowing decides one thing only: which packages are COMPARED against an
|
|
27
|
+
* infra version. It never relaxes the exact-pin rule, which `^`, `~` and
|
|
28
|
+
* `latest` break in EVERY `@onlineapps/*` dependency, gated or not
|
|
29
|
+
* (`.claude/rules/architecture-principles.md` § Version pinning) — a floating
|
|
30
|
+
* range makes the same commit install different code on different days whether
|
|
31
|
+
* or not infra happens to ship that package too. Until 2026-09-15 (W413) the
|
|
32
|
+
* exactness test sat inside the gated branch, so a biz-only pin could float
|
|
33
|
+
* past a green gate.
|
|
34
|
+
*
|
|
26
35
|
* Pure module: the rules and the fetch live here, presentation and exit codes
|
|
27
36
|
* live in the CLI.
|
|
28
37
|
*
|
|
@@ -85,40 +94,51 @@ function checkLibCompat(pkg, librarySet) {
|
|
|
85
94
|
const violations = [];
|
|
86
95
|
|
|
87
96
|
for (const [name, version] of ours) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
notGated.push(name);
|
|
97
|
+
const bizOnly = Boolean(infraConsumed) && !infraConsumed.has(name);
|
|
98
|
+
const declared = versions[name];
|
|
99
|
+
|
|
100
|
+
if (bizOnly && declared === undefined) {
|
|
101
|
+
violations.push({
|
|
102
|
+
package: name,
|
|
103
|
+
message: `${name}: unknown to the platform library SSOT `
|
|
104
|
+
+ "- declared in neither 'libraries' nor 'infraConsumed', so no platform version exists to pin against. "
|
|
105
|
+
+ `Fix: publish ${name} and add it to config/libraries.json, or remove the pin.`
|
|
106
|
+
});
|
|
99
107
|
continue;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
if (bizOnly) notGated.push(name);
|
|
111
|
+
else gated.push(name);
|
|
112
|
+
|
|
113
|
+
if (declared === undefined) {
|
|
104
114
|
violations.push({
|
|
105
115
|
package: name,
|
|
106
|
-
message: `${name}:
|
|
107
|
-
+ 'because what resolves today is not what resolved when the image was built.'
|
|
116
|
+
message: `${name}: not present in the infra library set — the platform does not ship this package version contract.`
|
|
108
117
|
});
|
|
109
118
|
continue;
|
|
110
119
|
}
|
|
111
|
-
|
|
120
|
+
|
|
121
|
+
// The exact-pin rule is unconditional: `.claude/rules/architecture-principles.md`
|
|
122
|
+
// § Version pinning bans `^`, `~` and `latest` in EVERY `@onlineapps/*`
|
|
123
|
+
// dependency, so it is checked before the narrowing has any say. Being "not
|
|
124
|
+
// gated" answers one question only — is there an infra version to diverge
|
|
125
|
+
// from — and it never answers whether the pin may float.
|
|
126
|
+
if (!EXACT_VERSION.test(version)) {
|
|
112
127
|
violations.push({
|
|
113
128
|
package: name,
|
|
114
|
-
message: `${name}:
|
|
129
|
+
message: `${name}: version '${version}' is not exact x.y.z — caret/tilde/range pins are banned, `
|
|
130
|
+
+ 'because what resolves today is not what resolved when the image was built. '
|
|
131
|
+
+ `Fix: npm install ${name}@${declared} --save-exact.`
|
|
115
132
|
});
|
|
116
133
|
continue;
|
|
117
134
|
}
|
|
118
|
-
|
|
135
|
+
|
|
136
|
+
if (bizOnly) continue;
|
|
137
|
+
|
|
138
|
+
if (declared !== version) {
|
|
119
139
|
violations.push({
|
|
120
140
|
package: name,
|
|
121
|
-
message: `${name}: biz pins ${version}, infra ships ${
|
|
141
|
+
message: `${name}: biz pins ${version}, infra ships ${declared} — `
|
|
122
142
|
+ 'rebuild the service against the current platform, or deploy the matching infra release first.'
|
|
123
143
|
});
|
|
124
144
|
}
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
27
|
|
|
28
|
+
const { KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE } = require('./stepFailure');
|
|
29
|
+
|
|
28
30
|
/**
|
|
29
31
|
* A step error is whatever the handler threw or the transport returned — often
|
|
30
32
|
* an object, not a string. The scripts this replaces interpolated it straight
|
|
@@ -42,10 +44,56 @@ function describeStepError(error) {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* One failed record of `results.steps`, in the shape the caller reports.
|
|
49
|
+
*
|
|
50
|
+
* `results.steps` holds TWO kinds of record and they are not both steps: a step
|
|
51
|
+
* result, and the entry `CookbookTestRunner.runCookbooks` pushes for a file the
|
|
52
|
+
* format check rejected — a whole cookbook that produced no step at all. Each
|
|
53
|
+
* DECLARES which it is in `kind`, written where the record is made (d.516b), so
|
|
54
|
+
* nothing here infers it from a missing field
|
|
55
|
+
* (`.claude/rules/architecture-principles.md` §8).
|
|
56
|
+
*
|
|
57
|
+
* This is the switchboard `utils/stepFailure.js` § describeStepFailureWithContext
|
|
58
|
+
* already keeps, for the structured half of the same answer: a cookbook that
|
|
59
|
+
* never ran is named by its FILE, because there is no step in it to name. Until
|
|
60
|
+
* d.518 both kinds were read as steps, so a rejected file reached the reader as
|
|
61
|
+
* `step_id: undefined` — a field naming nothing, about a step that does not
|
|
62
|
+
* exist, which `src/cli/biz-ci-gate.js` prints verbatim.
|
|
63
|
+
*
|
|
64
|
+
* The step's own `step_id` and nothing standing in for it: until d.516 this read
|
|
65
|
+
* `step.step_id || step.operation`, so a step that named no step_id was reported
|
|
66
|
+
* under its OPERATION — a field called `step_id` carrying something that is not
|
|
67
|
+
* one, which the reader has no way to see (§3, No Fallbacks). Since
|
|
68
|
+
* `@onlineapps/cookbook-core` 5.0.0 the schema requires `step_id` on every task
|
|
69
|
+
* step (`schemas/cookbook.v2.schema.json` definitions.TaskStep.required), and
|
|
70
|
+
* since d.460 the orchestrator refuses a task step that names no operation
|
|
71
|
+
* either (`@onlineapps/conn-orch-orchestrator` § _requireStepOperation), so a
|
|
72
|
+
* step arriving here without one is a broken cookbook — and a broken cookbook is
|
|
73
|
+
* what the outcome must show, not a plausible name.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} record a failed entry of `results.steps`
|
|
76
|
+
* @returns {{kind: string, error: string, validationErrors: string[]}} plus
|
|
77
|
+
* `cookbook` for a load failure, `step_id` for a step; WHICH of the two it is
|
|
78
|
+
* stays in `kind`, so a reader names it with `stepFailure.js`
|
|
79
|
+
* § describeRecordIdentity instead of picking a field
|
|
80
|
+
*/
|
|
81
|
+
function describeFailedRecord(record) {
|
|
82
|
+
const reason = {
|
|
83
|
+
error: describeStepError(record.error),
|
|
84
|
+
validationErrors: record.validationErrors || []
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (record.kind === KIND_COOKBOOK_LOAD_FAILURE) {
|
|
88
|
+
return { kind: KIND_COOKBOOK_LOAD_FAILURE, cookbook: record.cookbook, ...reason };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { kind: KIND_STEP, step_id: record.step_id, ...reason };
|
|
92
|
+
}
|
|
93
|
+
|
|
45
94
|
const COOKBOOKS_RELATIVE_DIR = path.join('tests', 'cookbooks');
|
|
46
95
|
const PROOF_RELATIVE_PATH = path.join('conn-runtime', 'validation-proof.json');
|
|
47
96
|
const OPERATIONS_RELATIVE_PATH = path.join('config', 'service', 'operations.json');
|
|
48
|
-
const VALIDATOR_NAME = '@onlineapps/conn-orch-validator';
|
|
49
97
|
const COOKBOOK_TIMEOUT_MS = 30000;
|
|
50
98
|
|
|
51
99
|
/**
|
|
@@ -104,7 +152,6 @@ async function runPreValidation({ serviceRoot, RunnerClass, ProofGeneratorClass,
|
|
|
104
152
|
const runner = new RunnerClass({
|
|
105
153
|
serviceName: packageJson.name,
|
|
106
154
|
servicePath: serviceRoot,
|
|
107
|
-
mockInfrastructure: true,
|
|
108
155
|
timeout: COOKBOOK_TIMEOUT_MS,
|
|
109
156
|
logger
|
|
110
157
|
});
|
|
@@ -117,12 +164,8 @@ async function runPreValidation({ serviceRoot, RunnerClass, ProofGeneratorClass,
|
|
|
117
164
|
// No proof on a failed run. A proof is a claim that the cookbooks passed,
|
|
118
165
|
// so writing one here would make the Registry trust a run that did not.
|
|
119
166
|
const failedSteps = results.steps
|
|
120
|
-
.filter((
|
|
121
|
-
.map(
|
|
122
|
-
step_id: step.step_id || step.operation,
|
|
123
|
-
error: describeStepError(step.error),
|
|
124
|
-
validationErrors: step.validationErrors || []
|
|
125
|
-
}));
|
|
167
|
+
.filter((record) => !record.passed)
|
|
168
|
+
.map(describeFailedRecord);
|
|
126
169
|
|
|
127
170
|
return { ok: false, results, failedSteps, proof: null, proofPath: null, filteredBy };
|
|
128
171
|
}
|
|
@@ -131,11 +174,13 @@ async function runPreValidation({ serviceRoot, RunnerClass, ProofGeneratorClass,
|
|
|
131
174
|
return { ok: true, results, failedSteps: [], proof: null, proofPath: null, filteredBy };
|
|
132
175
|
}
|
|
133
176
|
|
|
177
|
+
// The validator's own name and version are NOT passed in: they are this
|
|
178
|
+
// package's identity and the generator reads them from its own package.json
|
|
179
|
+
// (`src/validatorIdentity.js`, d.524). Passing them made this module carry a
|
|
180
|
+
// second copy of the package name.
|
|
134
181
|
const proofGenerator = new ProofGeneratorClass({
|
|
135
182
|
serviceName: packageJson.name,
|
|
136
|
-
serviceVersion: packageJson.version
|
|
137
|
-
validatorName: VALIDATOR_NAME,
|
|
138
|
-
validatorVersion: require('../../package.json').version
|
|
183
|
+
serviceVersion: packageJson.version
|
|
139
184
|
});
|
|
140
185
|
|
|
141
186
|
const proof = proofGenerator.generateProof(results, packageJson.dependencies || {});
|