@onlineapps/conn-orch-validator 10.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 +173 -0
- package/README.md +254 -10
- package/docs/DESIGN.md +11 -2
- package/manifests/biz-service.manifest.json +28 -1
- 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/gitCheckout.js +84 -0
- package/src/utils/dbAccountGrants.js +126 -0
- package/src/utils/envContract.js +36 -6
- package/src/utils/envReads.js +102 -0
- package/src/utils/stepReferences.js +278 -0
- package/src/validators/ServiceStructureValidator.js +7 -1
- package/templates/business-service/.gitlab-ci.yml +108 -10
- 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/src/cli/biz-ci-gate.js
CHANGED
|
@@ -35,7 +35,9 @@ const {
|
|
|
35
35
|
const { runPreValidation } = require('../utils/preValidation');
|
|
36
36
|
const { describeRecordIdentity } = require('../utils/stepFailure');
|
|
37
37
|
const { checkLibCompat, loadLibrarySet } = require('../utils/libCompat');
|
|
38
|
-
const { buildSchema } = require('../utils/setupDatabase');
|
|
38
|
+
const { buildSchema, defaultExec } = require('../utils/setupDatabase');
|
|
39
|
+
const { databaseAccountSql, ACCOUNT_HOST } = require('../utils/dbAccountGrants');
|
|
40
|
+
const { readDeclaredAccount, ACCOUNT_KEY, ENV_TEMPLATE_DIR } = require('../manifest/checks/serviceDb');
|
|
39
41
|
|
|
40
42
|
function parseArgs(argv) {
|
|
41
43
|
const parsed = {
|
|
@@ -101,6 +103,10 @@ Commands:
|
|
|
101
103
|
skipped; this reads the runner's own JSON report.
|
|
102
104
|
emit-ci-env Emit connector-aware CI environment exports.
|
|
103
105
|
wait-connectors Wait for required connector TCP endpoints.
|
|
106
|
+
setup-db-account Create this service's database account and grant it its own schemas.
|
|
107
|
+
The ONE step of a biz pipeline that runs under the database root
|
|
108
|
+
account, and the last one that does: every step after it connects
|
|
109
|
+
as the service, the way production does.
|
|
104
110
|
setup-db Build the schema from the declared migrations (contract database block).
|
|
105
111
|
run-setup Run optional connector setup commands from contract.
|
|
106
112
|
write-summary Write normalized integration signal artifact JSON.
|
|
@@ -131,6 +137,18 @@ wait-connectors options:
|
|
|
131
137
|
--attempts <number> Maximum connection attempts per connector (default: 60)
|
|
132
138
|
--interval-ms <number> Delay between attempts in milliseconds (default: 1000)
|
|
133
139
|
|
|
140
|
+
setup-db-account environment:
|
|
141
|
+
DB_HOST, DB_PORT The database this job stands up.
|
|
142
|
+
CI_DB_ROOT_USER The administrative account of that database — the sidecar's
|
|
143
|
+
CI_DB_ROOT_PASSWORD root, under its OWN name. It is never taken from DB_USER:
|
|
144
|
+
DB_USER is who the job connects AS, and the two being one
|
|
145
|
+
value is the thing this command exists to end.
|
|
146
|
+
DB_USER The account every later step connects as. It must be the one
|
|
147
|
+
the repository declares in ${ENV_TEMPLATE_DIR}/<service>.env.
|
|
148
|
+
DB_PASSWORD The password that account is CREATED with and connects with.
|
|
149
|
+
A throwaway value of the job; the env template declares the
|
|
150
|
+
key, never the secret.
|
|
151
|
+
|
|
134
152
|
write-summary options:
|
|
135
153
|
--output <path> Summary artifact path. Default: <service-root>/ci/integration-signal.json
|
|
136
154
|
--gate-verdict <pass|fail> Report a failure this summary cannot see for itself -
|
|
@@ -258,6 +276,146 @@ function reportInstallContract(result) {
|
|
|
258
276
|
return false;
|
|
259
277
|
}
|
|
260
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Fail with the one shape `architecture-principles.md` §5 asks for, and the
|
|
281
|
+
* prefix every other step of this gate prints.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} subject what was checked
|
|
284
|
+
* @param {string} problem what was found and what was expected
|
|
285
|
+
* @param {string} fix the command or edit that satisfies it
|
|
286
|
+
*/
|
|
287
|
+
function failSetupDbAccount(subject, problem, fix) {
|
|
288
|
+
process.stderr.write(`[BizCiGate] FAIL setup-db-account — ${subject} — ${problem}\n`
|
|
289
|
+
+ ` Fix: ${fix}\n`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Create this service's database account, and grant it its own schemas.
|
|
295
|
+
*
|
|
296
|
+
* This is the ONE step of a biz pipeline that runs under the database root
|
|
297
|
+
* account, and the last one that does. Everything after it — `setup-db`, the
|
|
298
|
+
* integration tier, the cookbooks — connects as the service, which is what makes
|
|
299
|
+
* CI prove the migration set under the rights production actually has
|
|
300
|
+
* (`db-migrations-first-deploy` 001: "migrace nikdy pod rootem"). Until d.567
|
|
301
|
+
* seven pipelines set `DB_USER: "root"` and proved it under rights no production
|
|
302
|
+
* box grants.
|
|
303
|
+
*
|
|
304
|
+
* Three facts, three owners, none of them invented here:
|
|
305
|
+
* - the SCHEMA is `database.schema` of the integration contract, the same
|
|
306
|
+
* value `setup-db` builds;
|
|
307
|
+
* - the ACCOUNT is `DB_USER` of the repository's own env template — the
|
|
308
|
+
* declaration uniform row `D-DB-ACCOUNT` measures, read through the one
|
|
309
|
+
* function that knows where it lives;
|
|
310
|
+
* - the STATEMENTS are `utils/dbAccountGrants.js`, the same definition the
|
|
311
|
+
* production runbook installs from.
|
|
312
|
+
*
|
|
313
|
+
* The password is the job's own throwaway value (`DB_PASSWORD`), never the
|
|
314
|
+
* `CHANGE_ME` placeholder an env template carries: a template declares the key,
|
|
315
|
+
* not the secret.
|
|
316
|
+
*/
|
|
317
|
+
function runSetupDbAccount(options) {
|
|
318
|
+
const contractInfo = loadAndValidateIntegrationContract(options.serviceRoot, options.contractPath);
|
|
319
|
+
const database = contractInfo.contract.database;
|
|
320
|
+
|
|
321
|
+
if (!database) {
|
|
322
|
+
// NOT APPLICABLE, not OK — the same distinction `setup-db` draws one
|
|
323
|
+
// function down, and for the same reason: a step that prints OK for work it
|
|
324
|
+
// never did is the false guarantee `automation-gates.md` §5 names.
|
|
325
|
+
process.stdout.write(`[BizCiGate] NOT APPLICABLE setup-db-account — ${contractInfo.contractPath} declares no `
|
|
326
|
+
+ '"database" block, so this service has no schema and needs no account. Nothing was done, and nothing '
|
|
327
|
+
+ 'was expected to be.\n');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const serviceRoot = contractInfo.serviceRoot;
|
|
332
|
+
const { relative, declared } = readDeclaredAccount(serviceRoot);
|
|
333
|
+
|
|
334
|
+
if (relative === null || declared === null) {
|
|
335
|
+
failSetupDbAccount(ACCOUNT_KEY,
|
|
336
|
+
`this repository declares a database, and ${relative === null
|
|
337
|
+
? `no env template of its own under ${ENV_TEMPLATE_DIR}/ declares ${ACCOUNT_KEY}`
|
|
338
|
+
: `${relative} declares no ${ACCOUNT_KEY}`} — the account is not this command's to invent`,
|
|
339
|
+
`declare ${ACCOUNT_KEY}=oagen_<service> in ${relative === null ? `${ENV_TEMPLATE_DIR}/<service>.env` : relative}`
|
|
340
|
+
+ '; uniform row D-DB-ACCOUNT measures the same key.');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const connecting = process.env.DB_USER;
|
|
344
|
+
if (connecting !== declared) {
|
|
345
|
+
failSetupDbAccount('DB_USER',
|
|
346
|
+
`this job connects as ${JSON.stringify(connecting ?? null)} and the repository declares `
|
|
347
|
+
+ `${JSON.stringify(declared)} in ${relative} — creating one account and using another would report `
|
|
348
|
+
+ 'a success nothing consumes, and fail two steps later as an access denial',
|
|
349
|
+
`set DB_USER: "${declared}" in the job's variables, the value ${relative} declares.`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const required = {
|
|
353
|
+
DB_HOST: process.env.DB_HOST,
|
|
354
|
+
CI_DB_ROOT_USER: process.env.CI_DB_ROOT_USER,
|
|
355
|
+
CI_DB_ROOT_PASSWORD: process.env.CI_DB_ROOT_PASSWORD,
|
|
356
|
+
DB_PASSWORD: process.env.DB_PASSWORD
|
|
357
|
+
};
|
|
358
|
+
const fixes = {
|
|
359
|
+
DB_HOST: 'set it in the job variables to the alias of the database service container.',
|
|
360
|
+
CI_DB_ROOT_USER: 'set it in the job variables to the administrative account of the database '
|
|
361
|
+
+ 'sidecar (root). It is named separately from DB_USER on purpose: DB_USER is who the job '
|
|
362
|
+
+ 'connects as afterwards.',
|
|
363
|
+
CI_DB_ROOT_PASSWORD: 'set it in the job variables to the sidecar\'s MARIADB_ROOT_PASSWORD. '
|
|
364
|
+
+ 'The sidecar is thrown away with the job, so the value is a throwaway of the job too.',
|
|
365
|
+
DB_PASSWORD: 'set it in the job variables to a throwaway password; the account is CREATED with '
|
|
366
|
+
+ 'it here and connects with it in every later step. The env template declares the key, never '
|
|
367
|
+
+ 'the secret.'
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
for (const [name, value] of Object.entries(required)) {
|
|
371
|
+
if (value === undefined || value === '') {
|
|
372
|
+
failSetupDbAccount(name, 'not set, and this step cannot run without it', fixes[name]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const connection = {
|
|
377
|
+
host: required.DB_HOST,
|
|
378
|
+
port: Number.parseInt(process.env.DB_PORT || '3306', 10),
|
|
379
|
+
user: required.CI_DB_ROOT_USER,
|
|
380
|
+
password: required.CI_DB_ROOT_PASSWORD,
|
|
381
|
+
requireTls: process.env.DB_REQUIRE_TLS === '1'
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Said before any statement runs, pass or fail — the way `verify-env-contract`
|
|
385
|
+
// prints its scope. A step that names nothing until it succeeds leaves the
|
|
386
|
+
// reader of a failed job log with a refused statement and no idea which
|
|
387
|
+
// account it was for.
|
|
388
|
+
process.stdout.write(`[BizCiGate] setup-db-account scope: '${declared}'@'${ACCOUNT_HOST}' on `
|
|
389
|
+
+ `${connection.host}:${connection.port}, schema \`${database.schema}\` — the account declared in `
|
|
390
|
+
+ `${relative}, created by ${JSON.stringify(connection.user)}\n`);
|
|
391
|
+
|
|
392
|
+
const statements = databaseAccountSql({
|
|
393
|
+
schema: database.schema,
|
|
394
|
+
account: declared,
|
|
395
|
+
password: required.DB_PASSWORD,
|
|
396
|
+
// The integration tier builds throwaway schemas beside the declared one
|
|
397
|
+
// (`utils/throwawaySchema.js`); production installs exactly one and gets no
|
|
398
|
+
// such grant. The difference is named here, at its one caller.
|
|
399
|
+
ciThrowaway: true
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
statements.forEach((sql, index) => {
|
|
403
|
+
const result = defaultExec({ connection, sql });
|
|
404
|
+
if (result.status !== 0) {
|
|
405
|
+
// The statement is identified by its ORDINAL, never quoted: the first one
|
|
406
|
+
// carries the account's password.
|
|
407
|
+
failSetupDbAccount(`statement ${index + 1} of ${statements.length}`,
|
|
408
|
+
`the database at ${connection.host}:${connection.port} refused it:\n ${result.stderr}`,
|
|
409
|
+
`check that ${JSON.stringify(connection.user)} is the administrative account of that database, `
|
|
410
|
+
+ 'and that a mariadb/mysql client is on PATH in this job image.');
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
process.stdout.write(`[BizCiGate] OK setup-db-account — '${declared}'@'${ACCOUNT_HOST}': `
|
|
415
|
+
+ `${statements.length} statement(s), granted \`${database.schema}\` and its throwaway namespace `
|
|
416
|
+
+ `\`${database.schema}_*\`. Every step after this one connects as that account.\n`);
|
|
417
|
+
}
|
|
418
|
+
|
|
261
419
|
/**
|
|
262
420
|
* Build the schema the contract declares. One implementation for every service:
|
|
263
421
|
* the repo declares WHAT, this decides HOW (docs/biz/00-model/uniformity-principle.md).
|
|
@@ -703,6 +861,10 @@ async function main() {
|
|
|
703
861
|
await runWaitConnectors(options);
|
|
704
862
|
return;
|
|
705
863
|
}
|
|
864
|
+
if (command === 'setup-db-account') {
|
|
865
|
+
runSetupDbAccount(options);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
706
868
|
if (command === 'setup-db') {
|
|
707
869
|
runSetupDb(options);
|
|
708
870
|
return;
|
package/src/cli/oa-validate.js
CHANGED
|
@@ -42,6 +42,7 @@ const { discoverBearers } = require('../manifest/discovery');
|
|
|
42
42
|
const { collectRows } = require('../manifest/walk');
|
|
43
43
|
const { CHECK_REGISTRY } = require('../manifest/checks');
|
|
44
44
|
const { declaredCategory, readPackage } = require('../manifest/checks/libraryContext');
|
|
45
|
+
const { listEnvReads } = require('../utils/envReads');
|
|
45
46
|
|
|
46
47
|
/** Exit code for a run that could not start at all — distinct from a finding. */
|
|
47
48
|
const USAGE_EXIT = 2;
|
|
@@ -84,6 +85,7 @@ Usage:
|
|
|
84
85
|
oa-validate --workspace <root> [--json]
|
|
85
86
|
oa-validate --library <packageDir> [--workspace <root>] [--json]
|
|
86
87
|
oa-validate --library --all [--workspace <root>] [--json]
|
|
88
|
+
oa-validate --env-reads [<serviceRoot>]
|
|
87
89
|
|
|
88
90
|
Checks a repository against the uniform manifest shipped in
|
|
89
91
|
@onlineapps/conn-orch-validator. The manifest version IS this package's
|
|
@@ -120,6 +122,15 @@ Options:
|
|
|
120
122
|
→ the rows that need it are reported NOT RUN, never as
|
|
121
123
|
passing.
|
|
122
124
|
--json Print the run as JSON instead of the table.
|
|
125
|
+
--env-reads Print the environment names this service reads — one per
|
|
126
|
+
line, sorted, nothing else — and exit 0. No table, no
|
|
127
|
+
banner, no verdict file: the output is read by a deploy
|
|
128
|
+
gate (mapfile), so it combines with no other mode. The set
|
|
129
|
+
is the one row C-ENV-READS measures against: the contract's
|
|
130
|
+
env block, the \${VAR} placeholders of config/service/*.json,
|
|
131
|
+
and the endpoint variables of the required connectors. A
|
|
132
|
+
name only the source reads is NOT here — that is a blocking
|
|
133
|
+
finding of C-ENV-READS with its own fix.
|
|
123
134
|
--help Print this and exit 0.
|
|
124
135
|
|
|
125
136
|
Output:
|
|
@@ -136,7 +147,9 @@ Exit codes:
|
|
|
136
147
|
`;
|
|
137
148
|
|
|
138
149
|
function parseArgs(argv) {
|
|
139
|
-
const parsed = {
|
|
150
|
+
const parsed = {
|
|
151
|
+
serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false, envReads: false
|
|
152
|
+
};
|
|
140
153
|
const args = [...argv];
|
|
141
154
|
|
|
142
155
|
while (args.length > 0) {
|
|
@@ -149,6 +162,8 @@ function parseArgs(argv) {
|
|
|
149
162
|
parsed.library = true;
|
|
150
163
|
} else if (token === '--all') {
|
|
151
164
|
parsed.all = true;
|
|
165
|
+
} else if (token === '--env-reads') {
|
|
166
|
+
parsed.envReads = true;
|
|
152
167
|
} else if (token === '--workspace') {
|
|
153
168
|
const value = args.shift();
|
|
154
169
|
if (!value || value.startsWith('--')) {
|
|
@@ -166,6 +181,27 @@ function parseArgs(argv) {
|
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
|
|
184
|
+
// Not a verdict and not combinable with one: the gate reads stdout as a list
|
|
185
|
+
// of names, so a table, a JSON document or a NOT RUN banner printed beside
|
|
186
|
+
// them would reach it as environment variables. The option that WOULD be
|
|
187
|
+
// harmless — `--workspace` — is refused with the rest, because this answer
|
|
188
|
+
// comes from the service's own contract and a workspace root would suggest it
|
|
189
|
+
// does not (`automation-gates.md` §1 requirement 1).
|
|
190
|
+
if (parsed.envReads && !parsed.help) {
|
|
191
|
+
const combined = [
|
|
192
|
+
parsed.json ? '--json' : null,
|
|
193
|
+
parsed.library ? '--library' : null,
|
|
194
|
+
parsed.all ? '--all' : null,
|
|
195
|
+
parsed.workspace !== null ? '--workspace' : null
|
|
196
|
+
].filter((token) => token !== null);
|
|
197
|
+
|
|
198
|
+
if (combined.length > 0) {
|
|
199
|
+
throw new Error(`[oa-validate] --env-reads cannot be combined with ${combined.join(', ')} - it prints `
|
|
200
|
+
+ 'environment names for a machine to read, and every other mode prints a verdict into the same '
|
|
201
|
+
+ 'stdout. Fix: run oa-validate --env-reads [<serviceRoot>] on its own.');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
if (parsed.all && !parsed.library) {
|
|
170
206
|
throw new Error('[oa-validate] Option --all needs --library - a service is checked one repository at a time. '
|
|
171
207
|
+ 'Fix: oa-validate --library --all');
|
|
@@ -403,6 +439,29 @@ function runLibrary(options) {
|
|
|
403
439
|
return blockingOf(report).length > 0 ? 1 : 0;
|
|
404
440
|
}
|
|
405
441
|
|
|
442
|
+
/**
|
|
443
|
+
* `--env-reads`: the question a deploy gate asks before it judges a `.env`.
|
|
444
|
+
*
|
|
445
|
+
* Nothing but the names reaches stdout, and nothing is written to the tree: the
|
|
446
|
+
* caller is `mapfile -t reads < <(oa-validate --env-reads "$repo")` in
|
|
447
|
+
* `api/scripts/validate-env.sh`, which took `KEY=CHANGE_ME` in a key the biz
|
|
448
|
+
* image never opens for a reason to refuse a deploy. The set, and why it is not
|
|
449
|
+
* a grep for `process.env`, belong to `utils/envReads.js`.
|
|
450
|
+
*
|
|
451
|
+
* An empty answer is an answer, and it exits 0: a contract that declares no env
|
|
452
|
+
* block, uses no placeholder and requires no connector reads nothing this
|
|
453
|
+
* package can see. Whether that is true of the repository is row `C-ENV-READS`'s
|
|
454
|
+
* question, not this one's.
|
|
455
|
+
*
|
|
456
|
+
* @param {object} options parsed arguments
|
|
457
|
+
* @returns {number} exit code
|
|
458
|
+
*/
|
|
459
|
+
function runEnvReads(options) {
|
|
460
|
+
const names = listEnvReads(options.serviceRoot === null ? process.cwd() : options.serviceRoot);
|
|
461
|
+
if (names.length > 0) process.stdout.write(`${names.join('\n')}\n`);
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
406
465
|
function main(argv) {
|
|
407
466
|
const options = parseArgs(argv);
|
|
408
467
|
|
|
@@ -411,6 +470,8 @@ function main(argv) {
|
|
|
411
470
|
return 0;
|
|
412
471
|
}
|
|
413
472
|
|
|
473
|
+
if (options.envReads) return runEnvReads(options);
|
|
474
|
+
|
|
414
475
|
if (options.library) return runLibrary(options);
|
|
415
476
|
|
|
416
477
|
const manifest = loadManifest(DEFAULT_MANIFEST_PATH);
|
|
@@ -471,6 +532,7 @@ if (require.main === module) {
|
|
|
471
532
|
module.exports = {
|
|
472
533
|
main,
|
|
473
534
|
parseArgs,
|
|
535
|
+
runEnvReads,
|
|
474
536
|
runLibrary,
|
|
475
537
|
checkOnePackage,
|
|
476
538
|
checkEveryPackage,
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
const fs = require('fs');
|
|
47
47
|
const path = require('path');
|
|
48
|
-
const {
|
|
48
|
+
const { gitCommand, listTrackedFiles, NOT_A_CHECKOUT } = require('../gitCheckout');
|
|
49
49
|
|
|
50
50
|
/** Never walked: neither is part of a repository's declared shape. */
|
|
51
51
|
const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
|
|
@@ -53,29 +53,6 @@ const NEVER_WALKED = Object.freeze(['node_modules', '.git']);
|
|
|
53
53
|
/** The file classes whose rows REQUIRE a path to be there. */
|
|
54
54
|
const REQUIRING_CLASSES = Object.freeze(['identical', 'contains', 'generated']);
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
* Run one git command in the repository, or return null when git itself could
|
|
58
|
-
* not answer. Nothing here guesses: the caller turns a null into NOT RUN.
|
|
59
|
-
*
|
|
60
|
-
* @param {string} serviceRoot
|
|
61
|
-
* @param {string[]} args
|
|
62
|
-
* @param {string} [input]
|
|
63
|
-
* @returns {string|null}
|
|
64
|
-
*/
|
|
65
|
-
function git(serviceRoot, args, input = undefined) {
|
|
66
|
-
try {
|
|
67
|
-
return execFileSync('git', ['-C', serviceRoot, ...args], {
|
|
68
|
-
encoding: 'utf8',
|
|
69
|
-
input,
|
|
70
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
71
|
-
});
|
|
72
|
-
} catch (error) {
|
|
73
|
-
// check-ignore exits 1 when nothing matched, which is an ANSWER.
|
|
74
|
-
if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
56
|
/**
|
|
80
57
|
* Does the path exist under EXACTLY this spelling?
|
|
81
58
|
*
|
|
@@ -161,7 +138,7 @@ const check = Object.freeze({
|
|
|
161
138
|
* @returns {{findings: Array<{where: string, what: string}>, notRun: string|null}}
|
|
162
139
|
*/
|
|
163
140
|
run({ block, serviceRoot }) {
|
|
164
|
-
const listed =
|
|
141
|
+
const listed = listTrackedFiles(serviceRoot);
|
|
165
142
|
if (listed === null) {
|
|
166
143
|
return {
|
|
167
144
|
findings: [],
|
|
@@ -174,10 +151,8 @@ const check = Object.freeze({
|
|
|
174
151
|
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
175
152
|
// Until d.518 it stopped at the absence, which is the dead end
|
|
176
153
|
// `.claude/rules/automation-gates.md` §1 requirement 4 forbids.
|
|
177
|
-
notRun:
|
|
178
|
-
+ '
|
|
179
|
-
+ 'Fix: run this row over a git checkout — a clone of the repository, never an export, a tarball '
|
|
180
|
-
+ 'or a built image.'
|
|
154
|
+
notRun: `${NOT_A_CHECKOUT} Fix: run this row over a git checkout — a clone of the repository, `
|
|
155
|
+
+ 'never an export, a tarball or a built image.'
|
|
181
156
|
};
|
|
182
157
|
}
|
|
183
158
|
|
|
@@ -191,7 +166,7 @@ const check = Object.freeze({
|
|
|
191
166
|
|
|
192
167
|
// Only now, and only for those: which of them a .gitignore rule covers is
|
|
193
168
|
// what turns the finding into an edit the reader can make.
|
|
194
|
-
const answered =
|
|
169
|
+
const answered = gitCommand(serviceRoot, ['check-ignore', '--stdin', '-z'], missing.map(({ file }) => file).join('\0'));
|
|
195
170
|
const ignored = new Set((answered === null ? '' : answered).split('\0').filter((entry) => entry.length > 0));
|
|
196
171
|
|
|
197
172
|
return {
|
|
@@ -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
|
};
|