@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.
@@ -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;
@@ -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 = { serviceRoot: null, workspace: null, json: false, help: false, library: false, all: false };
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 { execFileSync } = require('child_process');
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 = git(serviceRoot, ['ls-files', '-z']);
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: 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
178
- + 'would receive cannot be read — a container and an exported tarball are in exactly this state. '
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 = git(serviceRoot, ['check-ignore', '--stdin', '-z'], missing.map(({ file }) => file).join('\0'));
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 = shortNameOf(serviceRoot);
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
- const template = ownEnvTemplate(serviceRoot, shortName);
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: template.relative,
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: template.relative,
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
  };