@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
|
@@ -37,6 +37,7 @@ const path = require('path');
|
|
|
37
37
|
|
|
38
38
|
const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
39
39
|
const { DATABASE_PREFIX } = require('./serviceIdentityRows');
|
|
40
|
+
const { blockLines } = require('./serviceFiles');
|
|
40
41
|
|
|
41
42
|
/** Where the fact lives, and the key that carries it. */
|
|
42
43
|
const CONTRACT_PATH = 'config/service/integration-contract.json';
|
|
@@ -200,6 +201,39 @@ function ownEnvTemplate(serviceRoot, shortName) {
|
|
|
200
201
|
return { relative: only, text: fs.readFileSync(path.join(serviceRoot, ...only.split('/')), 'utf8') };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* WHICH account this repository declares, and where it said so.
|
|
206
|
+
*
|
|
207
|
+
* Exported because `D-DB-ACCOUNT` is not the only reader: `biz-ci-gate
|
|
208
|
+
* setup-db-account` CREATES that account in CI, and it must create the one the
|
|
209
|
+
* repository declares rather than one derived a second way. Which file carries
|
|
210
|
+
* the fact and under which key is therefore said ONCE
|
|
211
|
+
* (`change-discipline.md` § One rail per concern); the row below and the CLI
|
|
212
|
+
* command each phrase their own finding from the same reading.
|
|
213
|
+
*
|
|
214
|
+
* It is deliberately NOT the derived `oagen_<shortname>` — that is the row's
|
|
215
|
+
* comparison, and a caller that wanted the derivation instead of the
|
|
216
|
+
* declaration would be reading past the repository. Measured reason: the meta
|
|
217
|
+
* runbook names `oagen_meta_app` where the repository declares `oagen_meta`
|
|
218
|
+
* (`api/docs/setup/INSTALL.md`, open finding with an INFRA owner), so the two
|
|
219
|
+
* answers are not interchangeable today.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} serviceRoot repository root
|
|
222
|
+
* @returns {{shortName: string|null, relative: string|null, declared: string|null}}
|
|
223
|
+
* `shortName` null when the repository does not say who it is (`C-SERVICE`'s
|
|
224
|
+
* finding), `relative` null when it owns no env template, `declared` null when
|
|
225
|
+
* that template carries no such key
|
|
226
|
+
*/
|
|
227
|
+
function readDeclaredAccount(serviceRoot, key = ACCOUNT_KEY) {
|
|
228
|
+
const shortName = shortNameOf(serviceRoot);
|
|
229
|
+
if (shortName === null) return { shortName: null, relative: null, declared: null };
|
|
230
|
+
|
|
231
|
+
const template = ownEnvTemplate(serviceRoot, shortName);
|
|
232
|
+
if (template === null) return { shortName, relative: null, declared: null };
|
|
233
|
+
|
|
234
|
+
return { shortName, relative: template.relative, declared: declaredValue(template.text, key) };
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
/**
|
|
204
238
|
* The three rows below ask their question only of a repository that DECLARES a
|
|
205
239
|
* database, and say nothing about a declaration they could not read — that one
|
|
@@ -293,12 +327,11 @@ const dbAccount = Object.freeze({
|
|
|
293
327
|
run({ row, serviceRoot }) {
|
|
294
328
|
if (!hasDatabase(serviceRoot)) return [];
|
|
295
329
|
|
|
296
|
-
const shortName =
|
|
330
|
+
const { shortName, relative, declared } = readDeclaredAccount(serviceRoot, row.key);
|
|
297
331
|
if (shortName === null) return [];
|
|
298
332
|
|
|
299
333
|
const expected = `${DATABASE_PREFIX}${shortName}`;
|
|
300
|
-
|
|
301
|
-
if (template === null) {
|
|
334
|
+
if (relative === null) {
|
|
302
335
|
return [{
|
|
303
336
|
where: row.path,
|
|
304
337
|
what: `no env template of this service's own declares ${row.key}, and this service's account is `
|
|
@@ -306,23 +339,155 @@ const dbAccount = Object.freeze({
|
|
|
306
339
|
}];
|
|
307
340
|
}
|
|
308
341
|
|
|
309
|
-
const declared = declaredValue(template.text, row.key);
|
|
310
342
|
if (declared === null) {
|
|
311
343
|
return [{
|
|
312
|
-
where:
|
|
344
|
+
where: relative,
|
|
313
345
|
what: `declares no ${row.key}, and this service's account is ${JSON.stringify(expected)}`
|
|
314
346
|
}];
|
|
315
347
|
}
|
|
316
348
|
if (declared === expected) return [];
|
|
317
349
|
|
|
318
350
|
return [{
|
|
319
|
-
where:
|
|
351
|
+
where: relative,
|
|
320
352
|
what: `${row.key} is ${JSON.stringify(declared)}, and the account derived from ${IDENTITY_FILE} `
|
|
321
353
|
+ `is ${JSON.stringify(expected)}`
|
|
322
354
|
}];
|
|
323
355
|
}
|
|
324
356
|
});
|
|
325
357
|
|
|
358
|
+
/**
|
|
359
|
+
* `D-DB-CI-ACCOUNT` — the pipeline reaches the database as the SERVICE.
|
|
360
|
+
*
|
|
361
|
+
* `D-DB-ACCOUNT` one row up measures the DECLARATION: `DB_USER` in the env
|
|
362
|
+
* template, the account production installs. Nothing measured the ONE
|
|
363
|
+
* environment where that migration set is applied every day. Measured
|
|
364
|
+
* 2026-09-16 over the eight biz repositories: seven set `DB_USER: "root"` in
|
|
365
|
+
* their `test` job while declaring `oagen_<service>` in the template, so CI
|
|
366
|
+
* proved the set under rights no production box grants — the gap
|
|
367
|
+
* `db-migrations-first-deploy` 001 :32 names ("migrace nikdy pod rootem") in the
|
|
368
|
+
* only place it can be tried before a deploy.
|
|
369
|
+
*
|
|
370
|
+
* A SEPARATE row rather than a wider `D-DB-ACCOUNT`: another file, another fix,
|
|
371
|
+
* and one row with two fixes is two mechanisms under one name
|
|
372
|
+
* (`automation-gates.md` §1.2).
|
|
373
|
+
*
|
|
374
|
+
* ## What it reads, and what it leaves alone
|
|
375
|
+
*
|
|
376
|
+
* Only the lines OUTSIDE the `oa-ci v1` block. That block is the platform's and
|
|
377
|
+
* `G-CI` compares it to the template byte for byte; a second row reporting an
|
|
378
|
+
* edit there would be the duplicate `change-discipline.md` § One rail per
|
|
379
|
+
* concern forbids. Reading a REGION and not the whole file is also what keeps it
|
|
380
|
+
* clear of the defect that had the whole-file row over `.gitlab-ci.yml`
|
|
381
|
+
* withdrawn after three days: the other half of this file genuinely is the
|
|
382
|
+
* service's (`serviceFiles.js`, `delimited-block-render`).
|
|
383
|
+
*
|
|
384
|
+
* It asks WHO the database steps run as. It does NOT ask whether a repository
|
|
385
|
+
* runs them: a job that builds no schema has no account for the step to create,
|
|
386
|
+
* and demanding one would be this row inventing a rule
|
|
387
|
+
* (`truth-over-agreement.md` §6). Said out loud because a boundary nobody states
|
|
388
|
+
* is read as coverage (`automation-gates.md` §5).
|
|
389
|
+
*
|
|
390
|
+
* Silent for a service with no `database` block, by construction — `pdfgen` is
|
|
391
|
+
* the live case.
|
|
392
|
+
*
|
|
393
|
+
* @see api/docs/governance/confirmations/db-accounts-per-service.md
|
|
394
|
+
* @see api/docs/governance/confirmations/db-migrations-first-deploy.md
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The step that CREATES the account, and the step that first connects as it.
|
|
399
|
+
*
|
|
400
|
+
* Both are read on lines that are not comments. Measured in `api_biz/meta`: the
|
|
401
|
+
* `before_script` opens with a comment explaining why the job installs
|
|
402
|
+
* mariadb-client, and that comment NAMES `ci:gate:setup` — so a finding would
|
|
403
|
+
* have pointed the reader at a sentence instead of at the step, and a comment
|
|
404
|
+
* naming `setup-db-account` would have counted as running it.
|
|
405
|
+
*/
|
|
406
|
+
const ACCOUNT_STEP = /\bsetup-db-account\b/;
|
|
407
|
+
const SCHEMA_STEP = /\bci:gate:setup(?![\w:-])/;
|
|
408
|
+
const COMMENT = /^\s*#/;
|
|
409
|
+
|
|
410
|
+
/** `KEY: value`, as a YAML mapping line reads it — a `#` line declares nothing. */
|
|
411
|
+
const DECLARATION = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/;
|
|
412
|
+
|
|
413
|
+
const unquoted = (value) => value.trim().replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The other spelling of the same thing: `DB_USER=root` as a shell prefix on the
|
|
417
|
+
* step itself, so root is never left standing in the job environment.
|
|
418
|
+
*
|
|
419
|
+
* Measured shape, `api_biz/hello-service`:
|
|
420
|
+
* `- DB_USER=root DB_PASSWORD="$MYSQL_ROOT_PASSWORD" npm run ci:gate:setup`
|
|
421
|
+
* The intent is the better one, and the migration set is still applied by the
|
|
422
|
+
* superuser — which is the whole of what this row is about. A check reading only
|
|
423
|
+
* the `variables:` block would call that green, and a gate that is silent where
|
|
424
|
+
* it should speak is the false guarantee `automation-gates.md` §5 names.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} line one line of the file
|
|
427
|
+
* @param {string} key the variable name the row measures
|
|
428
|
+
* @returns {string|null} the value, or null when the line assigns nothing
|
|
429
|
+
*/
|
|
430
|
+
function inlineAssignment(line, key) {
|
|
431
|
+
if (/^\s*#/.test(line)) return null;
|
|
432
|
+
const match = line.match(new RegExp(`(?:^|[\\s;&|])${key}=(\\S*)`));
|
|
433
|
+
return match === null ? null : match[1].replace(/^["']|["']$/g, '');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const dbCiAccount = Object.freeze({
|
|
437
|
+
scope: 'service',
|
|
438
|
+
requires: Object.freeze(['path', 'block', 'key', 'account']),
|
|
439
|
+
|
|
440
|
+
run({ row, serviceRoot }) {
|
|
441
|
+
if (!hasDatabase(serviceRoot)) return [];
|
|
442
|
+
|
|
443
|
+
const file = path.join(serviceRoot, ...row.path.split('/'));
|
|
444
|
+
// Whether the file is there at all is `G-CI`'s finding, with its own fix.
|
|
445
|
+
if (!fs.existsSync(file)) return [];
|
|
446
|
+
|
|
447
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
448
|
+
const block = blockLines(fs.readFileSync(file, 'utf8'), row.block);
|
|
449
|
+
const blockStart = block.length === 0 ? -1 : lines.indexOf(block[0]);
|
|
450
|
+
const inBlock = (index) => blockStart !== -1 && index >= blockStart && index < blockStart + block.length;
|
|
451
|
+
|
|
452
|
+
const shortName = shortNameOf(serviceRoot);
|
|
453
|
+
const account = shortName === null ? null : `${DATABASE_PREFIX}${shortName}`;
|
|
454
|
+
const findings = [];
|
|
455
|
+
|
|
456
|
+
let accountStepAt = -1;
|
|
457
|
+
let schemaStepAt = -1;
|
|
458
|
+
|
|
459
|
+
lines.forEach((line, index) => {
|
|
460
|
+
if (inBlock(index)) return;
|
|
461
|
+
const isStep = !COMMENT.test(line);
|
|
462
|
+
if (isStep && accountStepAt === -1 && ACCOUNT_STEP.test(line)) accountStepAt = index;
|
|
463
|
+
if (isStep && schemaStepAt === -1 && SCHEMA_STEP.test(line)) schemaStepAt = index;
|
|
464
|
+
|
|
465
|
+
const declaration = line.match(DECLARATION);
|
|
466
|
+
const declared = declaration !== null && declaration[1] === row.key
|
|
467
|
+
? unquoted(declaration[2])
|
|
468
|
+
: inlineAssignment(line, row.key);
|
|
469
|
+
if (declared !== row.account) return;
|
|
470
|
+
|
|
471
|
+
findings.push({
|
|
472
|
+
where: `${row.path}:${index + 1}`,
|
|
473
|
+
what: `${row.key} is ${JSON.stringify(row.account)} — this pipeline applies the migration set as `
|
|
474
|
+
+ 'the database superuser, and production applies it as '
|
|
475
|
+
+ `${account === null ? 'the service account' : `the service account ${JSON.stringify(account)}`}`
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (schemaStepAt !== -1 && (accountStepAt === -1 || accountStepAt > schemaStepAt)) {
|
|
480
|
+
findings.push({
|
|
481
|
+
where: `${row.path}:${schemaStepAt + 1}`,
|
|
482
|
+
what: `ci:gate:setup connects as ${row.key}, and no setup-db-account step runs before it — the `
|
|
483
|
+
+ 'account it connects as is created by nothing'
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return findings;
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
326
491
|
/**
|
|
327
492
|
* `D-DB-COLLATION` — the schema is created with a collation this service CHOSE.
|
|
328
493
|
*
|
|
@@ -378,11 +543,15 @@ const dbCollation = Object.freeze({
|
|
|
378
543
|
});
|
|
379
544
|
|
|
380
545
|
module.exports = {
|
|
546
|
+
readDeclaredAccount,
|
|
547
|
+
ACCOUNT_KEY,
|
|
548
|
+
ENV_TEMPLATE_DIR,
|
|
381
549
|
checks: [
|
|
382
550
|
{ name: 'db-consistent', check: dbConsistent },
|
|
383
551
|
{ name: 'db-collation', check: dbCollation },
|
|
384
552
|
{ name: 'db-migration-naming', check: dbMigrationNaming },
|
|
385
553
|
{ name: 'db-readme', check: dbReadme },
|
|
386
|
-
{ name: 'db-account', check: dbAccount }
|
|
554
|
+
{ name: 'db-account', check: dbAccount },
|
|
555
|
+
{ name: 'db-ci-account', check: dbCiAccount }
|
|
387
556
|
]
|
|
388
557
|
};
|
|
@@ -27,9 +27,10 @@ const path = require('path');
|
|
|
27
27
|
|
|
28
28
|
const { readReferencedFile, isPackageReference, referenceOwner } = require('../discovery');
|
|
29
29
|
const { whereOf } = require('./libraryContext');
|
|
30
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
30
31
|
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
31
32
|
const {
|
|
32
|
-
SHARED_DECLARATIONS,
|
|
33
|
+
SHARED_DECLARATIONS, renderRunnerIdentity, stripDeclarations
|
|
33
34
|
} = require('./composeRunnerBlock');
|
|
34
35
|
const { renderText, topLevelKeys } = require('../../sync/serviceTemplate');
|
|
35
36
|
const { readIdentity, requireIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
@@ -51,7 +52,8 @@ const READS_ITS_REFERENCE_ONLY = Object.freeze({
|
|
|
51
52
|
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
52
53
|
|
|
53
54
|
describeNotRun({ row }) {
|
|
54
|
-
|
|
55
|
+
const owner = referenceOwner(row.from);
|
|
56
|
+
return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
|
|
55
57
|
}
|
|
56
58
|
});
|
|
57
59
|
|
|
@@ -433,9 +435,14 @@ const composeRunner = Object.freeze({
|
|
|
433
435
|
return findings;
|
|
434
436
|
}
|
|
435
437
|
|
|
438
|
+
// The reference is rendered for THIS repository and compared with the file
|
|
439
|
+
// as it stands — the direction the generator writes in, and the only one
|
|
440
|
+
// that is exact (`composeRunnerBlock.js` § renderRunnerIdentity, d.531).
|
|
441
|
+
// The three shared declarations come off both sides, because the rows above
|
|
442
|
+
// compare those against the service this runner tests.
|
|
436
443
|
const difference = firstDifference(
|
|
437
|
-
|
|
438
|
-
|
|
444
|
+
stripDeclarations(mine, SHARED_DECLARATIONS),
|
|
445
|
+
stripDeclarations(renderRunnerIdentity({ lines: reference, containerName, serviceName }), SHARED_DECLARATIONS)
|
|
439
446
|
);
|
|
440
447
|
if (difference !== null) {
|
|
441
448
|
say(`the "${row.block}" block differs from the template at block line ${difference.line}: `
|
|
@@ -600,6 +607,25 @@ const DOCKERIGNORE_BLOCK = 'oa-dockerignore v1';
|
|
|
600
607
|
*/
|
|
601
608
|
const DOCKER_IMPLICIT = Object.freeze(['.git']);
|
|
602
609
|
|
|
610
|
+
/**
|
|
611
|
+
* The test tree, which `.gitignore` must NOT declare and the image must not
|
|
612
|
+
* carry: tests live in the repository and never in the artefact that runs. It is
|
|
613
|
+
* the same decision the library uniform makes one row over as `L-PACK-TESTS`,
|
|
614
|
+
* applied to an image instead of a tarball — the reason is one, so the sentence
|
|
615
|
+
* is one (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
616
|
+
*
|
|
617
|
+
* The exception is the reason this is a list and not a line. `tests/cookbooks`
|
|
618
|
+
* is NOT a test: it is a declaration the RUNTIME reads. Tier-1 of the boot runs
|
|
619
|
+
* those cookbooks at phase 0.2, and an image without the directory measures zero
|
|
620
|
+
* of them, so `ValidationProofGenerator` refuses the proof as NO_TESTS
|
|
621
|
+
* (`src/validators/ValidationProofGenerator.js`) and the service never starts.
|
|
622
|
+
* Measured on 2026-09-16 against the wrapper's boot path.
|
|
623
|
+
*
|
|
624
|
+
* Order carries the meaning: in a `.dockerignore` a later line wins, so the
|
|
625
|
+
* re-inclusion stands AFTER the two exclusions and never before them.
|
|
626
|
+
*/
|
|
627
|
+
const DOCKER_TEST_TREE = Object.freeze(['tests', '**/tests', '!tests/cookbooks']);
|
|
628
|
+
|
|
603
629
|
/**
|
|
604
630
|
* The exclusions a `.dockerignore` must carry, DERIVED from the very declaration
|
|
605
631
|
* the `.gitignore` row reads. One list, two consumers
|
|
@@ -619,9 +645,9 @@ const DOCKER_IMPLICIT = Object.freeze(['.git']);
|
|
|
619
645
|
* A negation keeps its `!` in front of the pattern rather than inside it, and
|
|
620
646
|
* keeps its place in the order — in both formats a later line wins.
|
|
621
647
|
*
|
|
622
|
-
* What the derivation
|
|
623
|
-
*
|
|
624
|
-
* `
|
|
648
|
+
* What the derivation adds to the declaration is the test tree, and exactly one
|
|
649
|
+
* exception inside it — see `DOCKER_TEST_TREE` above. Nothing else: an entry the
|
|
650
|
+
* `.gitignore` does not declare has no business being invented here.
|
|
625
651
|
*
|
|
626
652
|
* @param {string} gitignoreText the declaration both rows read
|
|
627
653
|
* @returns {string[]}
|
|
@@ -640,6 +666,7 @@ function dockerignoreEntries(gitignoreText) {
|
|
|
640
666
|
if (!pattern.includes('/')) add(mark(`**/${pattern}`));
|
|
641
667
|
}
|
|
642
668
|
|
|
669
|
+
for (const entry of DOCKER_TEST_TREE) add(entry);
|
|
643
670
|
for (const entry of DOCKER_IMPLICIT) add(entry);
|
|
644
671
|
return entries;
|
|
645
672
|
}
|
|
@@ -46,6 +46,7 @@ const { readComposeServices } = require('./composeShape');
|
|
|
46
46
|
const { readIdentity, IDENTITY_FILE } = require('../serviceIdentity');
|
|
47
47
|
const { PLACEHOLDERS } = require('../../sync/serviceTemplate');
|
|
48
48
|
const { resolveFromMap } = require('../discovery');
|
|
49
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
49
50
|
|
|
50
51
|
/** Where a service declares the name npm knows it by. */
|
|
51
52
|
const PACKAGE_FILE = 'package.json';
|
|
@@ -271,7 +272,8 @@ const ssotIdentity = Object.freeze({
|
|
|
271
272
|
requires: Object.freeze([]),
|
|
272
273
|
|
|
273
274
|
describeNotRun({ block }) {
|
|
274
|
-
return `the workspace root is not reachable, so ${block.from.path} cannot be read
|
|
275
|
+
return `the workspace root is not reachable, so ${block.from.path} cannot be read. `
|
|
276
|
+
+ describeWorkspaceFix(block.from.path);
|
|
275
277
|
},
|
|
276
278
|
|
|
277
279
|
run({ block, serviceRoot, workspaceRoot }) {
|
|
@@ -44,6 +44,7 @@ const path = require('path');
|
|
|
44
44
|
|
|
45
45
|
const { resolveFromValue, isPackageReference, referenceOwner } = require('../discovery');
|
|
46
46
|
const { whereOf } = require('./libraryContext');
|
|
47
|
+
const { describeWorkspaceFix } = require('../workspaceRoot');
|
|
47
48
|
const { readComposeServices, scalarAt, declares, declarationOf } = require('./composeShape');
|
|
48
49
|
|
|
49
50
|
/** Where a service block states its own budget, in both compose files. */
|
|
@@ -52,6 +53,9 @@ const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
|
|
|
52
53
|
/** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
|
|
53
54
|
const TEST_PROFILE = 'test';
|
|
54
55
|
|
|
56
|
+
/** How a compose service names the identity its container runs as. */
|
|
57
|
+
const COMPOSE_USER_KEY = 'user';
|
|
58
|
+
|
|
55
59
|
const readOrNull = (serviceRoot, relative) => {
|
|
56
60
|
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
57
61
|
return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
@@ -106,7 +110,8 @@ const nodeMajor = Object.freeze({
|
|
|
106
110
|
needsWorkspace: ({ row }) => !isPackageReference(row.from),
|
|
107
111
|
|
|
108
112
|
describeNotRun({ row }) {
|
|
109
|
-
|
|
113
|
+
const owner = referenceOwner(row.from);
|
|
114
|
+
return `the workspace root is not reachable, so ${owner} cannot be read. ${describeWorkspaceFix(owner)}`;
|
|
110
115
|
},
|
|
111
116
|
|
|
112
117
|
run({ row, serviceRoot, workspaceRoot }) {
|
|
@@ -267,6 +272,125 @@ const composeCommand = Object.freeze({
|
|
|
267
272
|
}
|
|
268
273
|
});
|
|
269
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The instructions of ONE `FROM … AS <stage>` block, up to the next `FROM`.
|
|
277
|
+
*
|
|
278
|
+
* Comments are dropped, because the comment beside the identity names the very
|
|
279
|
+
* words this check reads and a comment is not an instruction.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} dockerfile
|
|
282
|
+
* @param {string} stage the name after `AS`
|
|
283
|
+
* @returns {string[]|null} null when the file declares no such stage
|
|
284
|
+
*/
|
|
285
|
+
function stageInstructions(dockerfile, stage) {
|
|
286
|
+
const lines = dockerfile.split('\n').map((line) => line.trim());
|
|
287
|
+
const starts = lines
|
|
288
|
+
.map((line, index) => ({ line, index }))
|
|
289
|
+
.filter((entry) => /^FROM\s/i.test(entry.line));
|
|
290
|
+
|
|
291
|
+
const wanted = starts.find((entry) => new RegExp(`\\sAS\\s+${stage}\\s*$`, 'i').test(entry.line));
|
|
292
|
+
if (wanted === undefined) return null;
|
|
293
|
+
|
|
294
|
+
const after = starts.find((entry) => entry.index > wanted.index);
|
|
295
|
+
return lines
|
|
296
|
+
.slice(wanted.index, after === undefined ? lines.length : after.index)
|
|
297
|
+
.filter((line) => line !== '' && !line.startsWith('#'));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `R-USER` — WHICH USER the service process is.
|
|
302
|
+
*
|
|
303
|
+
* Measured 2026-09-17 on a production image built from the platform template:
|
|
304
|
+
* `docker run --rm <image> id` answered `uid=0(root) gid=0(root)`, while the dev
|
|
305
|
+
* container beside it ran as `1000:1000` because `docker-compose.yml` pins it.
|
|
306
|
+
* Nobody had decided that difference — the production stage declared no `USER`
|
|
307
|
+
* at all — and nothing in the uniform could say so, because `Dockerfile` is a
|
|
308
|
+
* file of the `own` class. This row is the SECOND platform fact carved out of
|
|
309
|
+
* that class, for the same reason as the first (`R-NODE`, the node major): what
|
|
310
|
+
* a service BUILDS is its own, what it RUNS AS is the platform's.
|
|
311
|
+
*
|
|
312
|
+
* Two facts, and they are the two nothing else holds:
|
|
313
|
+
*
|
|
314
|
+
* * the `production` stage of the `Dockerfile` declares a non-root `USER` —
|
|
315
|
+
* that is the stage CI builds (`docker build --target production`), and the
|
|
316
|
+
* one the defect lived in;
|
|
317
|
+
* * the SERVICE node of the dev compose pins the same identity numerically.
|
|
318
|
+
*
|
|
319
|
+
* The other two places it appears already have an owner, and a second rail over
|
|
320
|
+
* them would report one fact twice (`change-discipline.md` § One rail per
|
|
321
|
+
* concern): the runner's `user:` sits inside the block `F-RUNNER` holds byte for
|
|
322
|
+
* byte, and the production compose is `G-PROD`'s whole-file render.
|
|
323
|
+
*
|
|
324
|
+
* The two spellings are one identity, and the row carries both because no
|
|
325
|
+
* machine can resolve an account NAME to a uid without the image: `node` is uid
|
|
326
|
+
* 1000, gid 1000 in the node images this platform builds on (measured
|
|
327
|
+
* `docker run --rm node:24-alpine id node`), which is the `1000:1000` the compose
|
|
328
|
+
* files pin. Same shape as `R-MEM` and `R-PID1`, which carry their values for
|
|
329
|
+
* the same reason.
|
|
330
|
+
*
|
|
331
|
+
* @see api/docs/biz/00-model/service-shape.md
|
|
332
|
+
*/
|
|
333
|
+
const processIdentity = Object.freeze({
|
|
334
|
+
scope: 'service',
|
|
335
|
+
requires: Object.freeze(['path', 'image_path', 'image_stage', 'service_user', 'service_uid']),
|
|
336
|
+
|
|
337
|
+
run({ row, serviceRoot, workspaceRoot }) {
|
|
338
|
+
const findings = [];
|
|
339
|
+
const identity = `the platform identity is ${JSON.stringify(row.service_user)} `
|
|
340
|
+
+ '(uid 1000, gid 1000 in the node images this platform builds on)';
|
|
341
|
+
const imageWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.image_path });
|
|
342
|
+
|
|
343
|
+
const dockerfile = readOrNull(serviceRoot, row.image_path);
|
|
344
|
+
if (dockerfile === null) {
|
|
345
|
+
findings.push({ where: imageWhere, what: 'absent — the image this service runs as is built from it' });
|
|
346
|
+
} else {
|
|
347
|
+
const stage = stageInstructions(dockerfile, row.image_stage);
|
|
348
|
+
if (stage === null) {
|
|
349
|
+
findings.push({
|
|
350
|
+
where: imageWhere,
|
|
351
|
+
what: `declares no stage "AS ${row.image_stage}" — that is the stage CI builds, so nothing here `
|
|
352
|
+
+ 'says what the deployed container runs as'
|
|
353
|
+
});
|
|
354
|
+
} else {
|
|
355
|
+
// The LAST one wins, the way docker reads it: a stage may switch twice,
|
|
356
|
+
// and what the container runs as is whatever stood at the end.
|
|
357
|
+
const declared = stage.filter((line) => /^USER\s/i.test(line)).pop();
|
|
358
|
+
if (declared === undefined) {
|
|
359
|
+
findings.push({
|
|
360
|
+
where: imageWhere,
|
|
361
|
+
what: `the ${row.image_stage} stage declares no USER, so the container runs as root — ${identity}`
|
|
362
|
+
});
|
|
363
|
+
} else if (declared.replace(/^USER\s+/i, '').trim() !== row.service_user) {
|
|
364
|
+
findings.push({
|
|
365
|
+
where: imageWhere,
|
|
366
|
+
what: `the ${row.image_stage} stage runs as ${JSON.stringify(declared.replace(/^USER\s+/i, '').trim())} — ${identity}`
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const composeWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
|
|
373
|
+
const compose = readOrNull(serviceRoot, row.path);
|
|
374
|
+
if (compose === null) {
|
|
375
|
+
findings.push({ where: composeWhere, what: 'absent — the service is run from it' });
|
|
376
|
+
return findings;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const [name, node] of runtimeServices(compose)) {
|
|
380
|
+
const declared = declarationOf(node, COMPOSE_USER_KEY).trim().replace(/^["']|["']$/g, '');
|
|
381
|
+
if (declared === row.service_uid) continue;
|
|
382
|
+
findings.push({
|
|
383
|
+
where: composeWhere,
|
|
384
|
+
what: declared === ''
|
|
385
|
+
? `service ${name} declares no user — the platform identity is ${JSON.stringify(row.service_uid)}`
|
|
386
|
+
: `service ${name} runs as ${JSON.stringify(declared)} — the platform identity is ${JSON.stringify(row.service_uid)}`
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return findings;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
270
394
|
const composeNoPorts = Object.freeze({
|
|
271
395
|
scope: 'service',
|
|
272
396
|
requires: Object.freeze(['path']),
|
|
@@ -289,6 +413,7 @@ module.exports = {
|
|
|
289
413
|
{ name: 'node-major', check: nodeMajor },
|
|
290
414
|
{ name: 'memory-limit', check: memoryLimit },
|
|
291
415
|
{ name: 'compose-command', check: composeCommand },
|
|
416
|
+
{ name: 'process-identity', check: processIdentity },
|
|
292
417
|
{ name: 'compose-no-ports', check: composeNoPorts }
|
|
293
418
|
],
|
|
294
419
|
majorOf
|
|
@@ -237,21 +237,38 @@ function resolveFromValue({ from, workspaceRoot }) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
/**
|
|
240
|
-
* The
|
|
240
|
+
* The whole referenced file as the document it is: `{ path, text: true }` over a
|
|
241
|
+
* JSON SSOT, for a row that does not take a value out of it but hands it to the
|
|
242
|
+
* module that owns its shape (`G-SHARED-ENV` renders `api/config/shared-env.json`
|
|
243
|
+
* through `src/sync/sharedEnv.js`).
|
|
241
244
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
245
|
+
* It is the same read and the same refusal `resolveFromMap` makes, said once:
|
|
246
|
+
* a broken SSOT is reported as a broken SSOT wherever a row reads it, and the
|
|
247
|
+
* second call site is the one that would have written its own sentence
|
|
248
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
249
|
+
*
|
|
250
|
+
* @param {{ from: {path: string}, workspaceRoot: string }} params
|
|
251
|
+
* @returns {object|Array} the parsed document
|
|
244
252
|
*/
|
|
245
|
-
function
|
|
253
|
+
function resolveFromDocument({ from, workspaceRoot }) {
|
|
246
254
|
const raw = readReferencedFile({ from, workspaceRoot });
|
|
247
255
|
|
|
248
|
-
let document;
|
|
249
256
|
try {
|
|
250
|
-
|
|
257
|
+
return JSON.parse(raw);
|
|
251
258
|
} catch (cause) {
|
|
252
|
-
throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${from
|
|
259
|
+
throw new Error(`[ManifestDiscovery] Referenced file is not valid JSON - ${referenceOwner(from)}. `
|
|
253
260
|
+ 'Fix: repair the file; it is the SSOT this row reads.', { cause });
|
|
254
261
|
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The node a `from.list` path points at, as the owner file writes it.
|
|
266
|
+
*
|
|
267
|
+
* @param {{ from: {path: string, list: string}, workspaceRoot: string }} params
|
|
268
|
+
* @returns {Array|object} the array or the object map the reference names
|
|
269
|
+
*/
|
|
270
|
+
function resolveFromMap({ from, workspaceRoot }) {
|
|
271
|
+
const document = resolveFromDocument({ from, workspaceRoot });
|
|
255
272
|
|
|
256
273
|
const node = from.list.split('.').reduce((current, key) => (current == null ? undefined : current[key]), document);
|
|
257
274
|
if (node === null || node === undefined || typeof node !== 'object') {
|
|
@@ -378,6 +395,7 @@ module.exports = {
|
|
|
378
395
|
resolveFromValue,
|
|
379
396
|
resolveFromMap,
|
|
380
397
|
readReferencedFile,
|
|
398
|
+
resolveFromDocument,
|
|
381
399
|
expandPattern,
|
|
382
400
|
rootOfPattern,
|
|
383
401
|
containerOf,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one probe that answers "is this tree a git checkout" — and the one command
|
|
5
|
+
* that asks it.
|
|
6
|
+
*
|
|
7
|
+
* Two places need the answer and they must never disagree:
|
|
8
|
+
*
|
|
9
|
+
* - the `git-tracked` row (`checks/gitTracked.js`), which asks what a clone
|
|
10
|
+
* would receive and reports NOT RUN where git cannot say;
|
|
11
|
+
* - step 7 of `ValidationOrchestrator`, which measures the whole uniform and,
|
|
12
|
+
* outside a checkout, is NOT RUN for the same reason (owner decision
|
|
13
|
+
* 2026-09-17, confirmation `api/docs/governance/confirmations/biz-service-manifest.md`
|
|
14
|
+
* 011).
|
|
15
|
+
*
|
|
16
|
+
* A second copy of `git ls-files` would be a second rail for one concern
|
|
17
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern) and, worse, two
|
|
18
|
+
* rails that can answer differently: one of them would eventually grow a
|
|
19
|
+
* `.git` existence check, which is not the same question — a worktree, a
|
|
20
|
+
* submodule and a `$GIT_DIR` elsewhere are checkouts without a `.git`
|
|
21
|
+
* DIRECTORY, and a directory called `.git` inside an exported tarball is not a
|
|
22
|
+
* checkout. What decides is whether git itself can answer.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here guesses: a command git could not answer returns `null`, and the
|
|
25
|
+
* caller turns that into NOT RUN. What a negative answer MEANS is stated here
|
|
26
|
+
* once, in `NOT_A_CHECKOUT`; the remedy is the caller's own, because a row and a
|
|
27
|
+
* whole step do not fix the same way.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { execFileSync } = require('child_process');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a negative answer means — the half both callers state identically, so it
|
|
34
|
+
* cannot drift into two versions of one fact. Each of them appends its own
|
|
35
|
+
* remedy sentence after it.
|
|
36
|
+
*/
|
|
37
|
+
const NOT_A_CHECKOUT = 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
|
|
38
|
+
+ 'would receive cannot be read — a container and an exported tarball are in exactly this state.';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run one git command in the tree, or return null when git itself could not
|
|
42
|
+
* answer.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} root the directory to run in
|
|
45
|
+
* @param {string[]} args the git arguments, without `-C <root>`
|
|
46
|
+
* @param {string} [input] stdin for commands that read it
|
|
47
|
+
* @returns {string|null} stdout, or null when git could not answer
|
|
48
|
+
*/
|
|
49
|
+
function gitCommand(root, args, input = undefined) {
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
input,
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// check-ignore exits 1 when nothing matched, which is an ANSWER.
|
|
58
|
+
if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What git tracks in this tree, NUL-separated exactly as `git ls-files -z`
|
|
65
|
+
* printed it — or null when this is not a checkout.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} root
|
|
68
|
+
* @returns {string|null}
|
|
69
|
+
*/
|
|
70
|
+
function listTrackedFiles(root) {
|
|
71
|
+
return gitCommand(root, ['ls-files', '-z']);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is this tree a git checkout?
|
|
76
|
+
*
|
|
77
|
+
* @param {string} root
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function isGitCheckout(root) {
|
|
81
|
+
return listTrackedFiles(root) !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { gitCommand, listTrackedFiles, isGitCheckout, NOT_A_CHECKOUT };
|