@onlineapps/conn-orch-validator 10.0.0 → 12.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 +225 -0
- package/README.md +254 -10
- package/docs/DESIGN.md +11 -2
- package/manifests/biz-service.manifest.json +29 -2
- package/package.json +2 -1
- package/src/CookbookTestRunner.js +50 -6
- package/src/ValidationOrchestrator.js +244 -58
- package/src/cli/biz-ci-gate.js +163 -1
- package/src/cli/oa-validate.js +63 -1
- package/src/manifest/checks/gitTracked.js +5 -30
- package/src/manifest/checks/serviceDb.js +176 -7
- package/src/manifest/checks/serviceRuntime.js +123 -0
- package/src/manifest/discovery.js +42 -3
- package/src/manifest/gitCheckout.js +84 -0
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/deployContract.js +116 -6
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/stepReferences.js +278 -0
- package/src/utils/testNamespace.js +30 -3
- package/src/validators/ServiceStructureValidator.js +7 -1
- package/templates/business-service/.gitlab-ci.yml +127 -17
- package/templates/business-service/Dockerfile +49 -16
- package/templates/business-service/README.md +42 -4
- package/templates/business-service/config/env-templates/shared.env +1 -1
- package/templates/business-service/docker-compose.production.yml +9 -0
- package/templates/business-service/docker-compose.yml +17 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +3 -2
|
@@ -53,6 +53,9 @@ const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
|
|
|
53
53
|
/** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
|
|
54
54
|
const TEST_PROFILE = 'test';
|
|
55
55
|
|
|
56
|
+
/** How a compose service names the identity its container runs as. */
|
|
57
|
+
const COMPOSE_USER_KEY = 'user';
|
|
58
|
+
|
|
56
59
|
const readOrNull = (serviceRoot, relative) => {
|
|
57
60
|
const target = path.join(serviceRoot, ...relative.split('/'));
|
|
58
61
|
return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
@@ -269,6 +272,125 @@ const composeCommand = Object.freeze({
|
|
|
269
272
|
}
|
|
270
273
|
});
|
|
271
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
|
+
|
|
272
394
|
const composeNoPorts = Object.freeze({
|
|
273
395
|
scope: 'service',
|
|
274
396
|
requires: Object.freeze(['path']),
|
|
@@ -291,6 +413,7 @@ module.exports = {
|
|
|
291
413
|
{ name: 'node-major', check: nodeMajor },
|
|
292
414
|
{ name: 'memory-limit', check: memoryLimit },
|
|
293
415
|
{ name: 'compose-command', check: composeCommand },
|
|
416
|
+
{ name: 'process-identity', check: processIdentity },
|
|
294
417
|
{ name: 'compose-no-ports', check: composeNoPorts }
|
|
295
418
|
],
|
|
296
419
|
majorOf
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
|
|
24
|
-
const { resolveWorkspacePath } = require('./workspaceRoot');
|
|
24
|
+
const { resolveWorkspacePath, apiCheckoutOf } = require('./workspaceRoot');
|
|
25
25
|
|
|
26
26
|
/** Never walked: neither is part of any repository's declared shape. */
|
|
27
27
|
const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
|
|
@@ -211,11 +211,50 @@ function readReferencedFile({ from, workspaceRoot }) {
|
|
|
211
211
|
try {
|
|
212
212
|
return fs.readFileSync(file, 'utf8');
|
|
213
213
|
} catch (cause) {
|
|
214
|
-
throw new Error(
|
|
215
|
-
+ 'Fix: run with --workspace pointing at the directory that holds api/ and api_biz/.', { cause });
|
|
214
|
+
throw new Error(describeMissingReference({ from, workspaceRoot, file }), { cause });
|
|
216
215
|
}
|
|
217
216
|
}
|
|
218
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Why a `from:` file could not be read, said as the two different things it can
|
|
220
|
+
* be — because the reader's next move is a different one in each case.
|
|
221
|
+
*
|
|
222
|
+
* The run was pointed at a workspace that carries no api checkout: the layout is
|
|
223
|
+
* the problem, and `--workspace` is the answer it has always been.
|
|
224
|
+
*
|
|
225
|
+
* The run REACHED the checkout and the file is not in it: the layout is right
|
|
226
|
+
* and the advice to pass `--workspace` is one nobody can carry out — a CI job
|
|
227
|
+
* passes that flag itself. Measured on BIZ-invoicing 2026-09-17: the job reached
|
|
228
|
+
* `api/`, and the clone made at the api ref that job measures against did not
|
|
229
|
+
* carry `api/config/shared-env.json`, because that ref predates the file. What
|
|
230
|
+
* moves is the checkout (locally) or the ref (in CI), so the message names both,
|
|
231
|
+
* and it names the ref by variable — the two of confirmation
|
|
232
|
+
* `biz-service-manifest` 012.
|
|
233
|
+
*
|
|
234
|
+
* It stays a hard failure in both cases: a missing SSOT in a workspace this run
|
|
235
|
+
* CAN read is a finding, never a NOT RUN (`automation-gates.md` §5).
|
|
236
|
+
*
|
|
237
|
+
* @param {{ from: {path: string}, workspaceRoot: string, file: string }} params
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
function describeMissingReference({ from, workspaceRoot, file }) {
|
|
241
|
+
const apiRoot = apiCheckoutOf(workspaceRoot);
|
|
242
|
+
const insideApiCheckout = apiRoot !== null
|
|
243
|
+
&& path.resolve(file).startsWith(apiRoot + path.sep);
|
|
244
|
+
|
|
245
|
+
if (!insideApiCheckout) {
|
|
246
|
+
return `[ManifestDiscovery] Referenced file not found - ${from.path} under ${workspaceRoot}. `
|
|
247
|
+
+ 'Fix: run with --workspace pointing at the directory that holds api/ and api_biz/.';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return `[ManifestDiscovery] Referenced file not found - ${from.path} is missing from the api checkout `
|
|
251
|
+
+ `${apiRoot}, which this run reached. The workspace is there and so is the checkout, so this is `
|
|
252
|
+
+ 'not a --workspace problem: the checkout does not carry the file at the state it stands on. '
|
|
253
|
+
+ `Fix: locally, update that checkout (git -C ${apiRoot} pull); in CI, move the api ref this job `
|
|
254
|
+
+ 'measures against to a commit that carries the file - API_UNIFORM_REF_CONTINUOUS for the '
|
|
255
|
+
+ 'validate-uniform job, API_UNIFORM_REF_DEPLOY for deploy-production.';
|
|
256
|
+
}
|
|
257
|
+
|
|
219
258
|
/**
|
|
220
259
|
* The whole referenced file as one value: `{ path, text: true }`.
|
|
221
260
|
*
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* R8 permits and their reasons: api/docs/standards/tenant-allocation.md § Enforcement
|
|
9
9
|
*
|
|
10
10
|
* R1 the production compose pins its image immutably
|
|
11
|
-
* R2 the deploy resets to
|
|
11
|
+
* R2 the deploy never merges: it resets to the commit the pipeline measured,
|
|
12
|
+
* after verifying that commit is an ancestor of origin/production
|
|
12
13
|
* R3 CI Node major == Dockerfile Node major == engines.node major
|
|
13
14
|
* R4 no published ports — the only public entrypoint is doorman
|
|
14
15
|
* R5 images tagged with the full commit SHA, never the short one
|
|
@@ -138,15 +139,124 @@ function checkPublishedPorts(compose, add) {
|
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// ── R2 — the deploy sequence ────────────────────────────────────────────────
|
|
143
|
+
//
|
|
144
|
+
// The rule, stated once and in full: a deploy on the box NEVER merges; it forces
|
|
145
|
+
// the checkout to the commit THIS PIPELINE MEASURED, and before it does, it
|
|
146
|
+
// verifies that commit is an ancestor of origin/production.
|
|
147
|
+
//
|
|
148
|
+
// Both halves are load-bearing, and each answers a different way a box ends up
|
|
149
|
+
// running something nobody measured:
|
|
150
|
+
//
|
|
151
|
+
// the measured commit — the runner read config/service/integration-contract.json
|
|
152
|
+
// at $CI_COMMIT_SHA and derived the migration directory and the seed list from
|
|
153
|
+
// it THERE. Resetting to origin/production instead lands the box on whatever
|
|
154
|
+
// the branch points at by the time the ssh step opens, so a push that arrives
|
|
155
|
+
// while the job runs leaves it applying a declaration no run ever measured.
|
|
156
|
+
//
|
|
157
|
+
// the ancestor check — a commit that is not on the branch this box follows is
|
|
158
|
+
// a pipeline for a different history, and resetting to it puts the box on code
|
|
159
|
+
// production never took. Fail-fast, before anything is touched.
|
|
160
|
+
//
|
|
161
|
+
// Until 2026-09-17 this function demanded the literal `git reset --hard
|
|
162
|
+
// origin/production`, which is the FIRST half's opposite. The packaged template
|
|
163
|
+
// had carried the two-step form since d.589b, so a repository that synced the
|
|
164
|
+
// block failed the gate byte-for-byte conformant — measured in pdfgen and
|
|
165
|
+
// hello-service, both of which run `ci:gate:contract` as the first before_script
|
|
166
|
+
// step of `test` and would therefore have failed before their first test. A rule
|
|
167
|
+
// that states yesterday's TEXT rather than the requirement is exactly what
|
|
168
|
+
// `.claude/rules/doc-code-binding.md` §1 calls a descriptive fact written by
|
|
169
|
+
// hand; `tests/unit/deployContractTemplate.test.js` is what now re-derives it,
|
|
170
|
+
// by running this gate over the template this package ships.
|
|
171
|
+
//
|
|
172
|
+
// There is no transition tolerance for the old form, and there must not be
|
|
173
|
+
// (`architecture-principles.md` §11): the six repositories still carrying it get
|
|
174
|
+
// a message naming today's form and the one command that writes it.
|
|
175
|
+
|
|
176
|
+
/** The branch a production box follows — the only ref an ancestor check may name. */
|
|
177
|
+
const PRODUCTION_BRANCH = 'origin/production';
|
|
178
|
+
|
|
179
|
+
/** `git reset --hard <ref>`, tolerant of the spacing a shell allows. */
|
|
180
|
+
const RESET_HARD = /(?:^|[^\w-])git\s+reset\s+--hard\s+(\S+)/g;
|
|
181
|
+
|
|
182
|
+
/** `git merge-base --is-ancestor <commit> <ref>` — the guard, same tolerance. */
|
|
183
|
+
const ANCESTOR_CHECK = /(?:^|[^\w-])git\s+merge-base\s+--is-ancestor\s+(\S+)\s+(\S+)/g;
|
|
184
|
+
|
|
185
|
+
/** `git merge …`. `git merge-base` is a different command, so it must not match. */
|
|
186
|
+
const GIT_MERGE = /(?:^|[^\w-])git\s+merge(?![-\w])/;
|
|
187
|
+
|
|
188
|
+
/** `git pull …` */
|
|
189
|
+
const GIT_PULL = /(?:^|[^\w-])git\s+pull(?:[^\w-]|$)/m;
|
|
190
|
+
|
|
191
|
+
/** The block this package ships, named in every fix so nobody has to retype it. */
|
|
192
|
+
const DEPLOY_SEQUENCE_FIX =
|
|
193
|
+
' Fix: npx oa-sync-template .gitlab-ci.yml --target . — the packaged block `oa-ci v1`\n'
|
|
194
|
+
+ ' carries the sequence: git fetch origin production, then\n'
|
|
195
|
+
+ ' `if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/production; then exit 1; fi`,\n'
|
|
196
|
+
+ ' then `git reset --hard "$COMMIT_SHA"`.';
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* One shell word as this rule reads it: the separators a shell puts after an
|
|
200
|
+
* argument (`; then`, `&& …`) dropped, then the quotes.
|
|
201
|
+
*
|
|
202
|
+
* Tolerant, deliberately not lenient: what it normalises away is punctuation
|
|
203
|
+
* that cannot change which commit the command names.
|
|
204
|
+
*/
|
|
205
|
+
function shellWord(raw) {
|
|
206
|
+
return unquote(raw.replace(/[;&|)]+$/, ''));
|
|
207
|
+
}
|
|
208
|
+
|
|
141
209
|
function checkDeploySequence(ci, add) {
|
|
142
|
-
if (
|
|
210
|
+
if (GIT_PULL.test(ci)) {
|
|
143
211
|
add('R2', "'git pull' in the deploy path.\n"
|
|
144
212
|
+ ' A local modification on the server turns the merge into a conflict and aborts the deploy halfway.\n'
|
|
145
|
-
+
|
|
213
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
146
214
|
}
|
|
147
|
-
if (
|
|
148
|
-
add('R2', "
|
|
149
|
-
+ '
|
|
215
|
+
if (GIT_MERGE.test(ci)) {
|
|
216
|
+
add('R2', "'git merge' in the deploy path.\n"
|
|
217
|
+
+ ' A deploy that merges builds a commit that exists on no branch, so what the box runs\n'
|
|
218
|
+
+ ' is no longer any commit a pipeline measured.\n'
|
|
219
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const resets = [...ci.matchAll(RESET_HARD)]
|
|
223
|
+
.map((match) => ({ at: match.index, target: shellWord(match[1]) }));
|
|
224
|
+
|
|
225
|
+
if (resets.length === 0) {
|
|
226
|
+
add('R2', "No 'git reset --hard' found in .gitlab-ci.yml.\n"
|
|
227
|
+
+ ' The deploy has to force the working tree to the commit this pipeline measured;\n'
|
|
228
|
+
+ ' nothing here does.\n'
|
|
229
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Ordering is read over the whole file rather than per job: which YAML key a
|
|
234
|
+
// line sits under is the service's own half of the pipeline, and a gate that
|
|
235
|
+
// parsed jobs would be judging that half too. What it does measure is the
|
|
236
|
+
// thing that can go wrong — a guard that names another commit, another branch,
|
|
237
|
+
// or runs after the reset it is supposed to protect.
|
|
238
|
+
const guards = [...ci.matchAll(ANCESTOR_CHECK)]
|
|
239
|
+
.map((match) => ({ at: match.index, commit: shellWord(match[1]), branch: shellWord(match[2]) }))
|
|
240
|
+
.filter((guard) => guard.branch === PRODUCTION_BRANCH);
|
|
241
|
+
|
|
242
|
+
for (const reset of resets) {
|
|
243
|
+
if (reset.target === PRODUCTION_BRANCH) {
|
|
244
|
+
add('R2', `The deploy resets to ${PRODUCTION_BRANCH}, not to the commit this pipeline measured.\n`
|
|
245
|
+
+ ' origin/production is whatever the branch points at by the time the ssh step opens:\n'
|
|
246
|
+
+ ' a push landing while this job runs leaves the box applying a declaration no run\n'
|
|
247
|
+
+ ' ever measured, because the migration set was derived at the pipeline commit.\n'
|
|
248
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!guards.some((guard) => guard.at < reset.at && guard.commit === reset.target)) {
|
|
252
|
+
add('R2', `The deploy resets to ${reset.target}, which is not verified to be an ancestor of `
|
|
253
|
+
+ `${PRODUCTION_BRANCH} first.\n`
|
|
254
|
+
+ ' A commit that is not on the branch this box follows belongs to a different history,\n'
|
|
255
|
+
+ ' and resetting to it puts the box on code production never took.\n'
|
|
256
|
+
+ ` Expected: git merge-base --is-ancestor ${reset.target} ${PRODUCTION_BRANCH}, on a line\n`
|
|
257
|
+
+ ' BEFORE the reset, naming that same commit.\n'
|
|
258
|
+
+ DEPLOY_SEQUENCE_FIX);
|
|
259
|
+
}
|
|
150
260
|
}
|
|
151
261
|
}
|
|
152
262
|
|
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
|