@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
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
],
|
|
109
109
|
"own": {
|
|
110
110
|
"concern": "the files of the template whose CONTENT the service decides, and the reason each is not held to the platform's copy",
|
|
111
|
-
"why": "a file added to the template is a decision about who guards it, and this class is the decision written down: no row holds these to the template, because their content IS the service. Dockerfile is the exception that proves the rule - its
|
|
111
|
+
"why": "a file added to the template is a decision about who guards it, and this class is the decision written down: no row holds these to the template, because their content IS the service. Dockerfile is the exception that proves the rule - its TWO platform facts are rows of their own, and everything else in it is this service's build: the node major is R-NODE's row, and WHICH USER the process runs as is R-USER's (added d.588c, after a production image built from the template was measured running as uid 0 while the dev container beside it ran as 1000). What a service BUILDS is its own; what it RUNS AS is the platform's. docs/README.md is the same decision for the map of the documentation tree: which branches a service has is the service's, and what the platform holds the tree to is not that prose but D-LINT, which asks the documentation lint whether the tree is clean. It is the map alone, not docs/ - the three installation documents under docs/80-setup/ ARE held to a skeleton, and a class covering them would exempt what a row requires. The class exists so that the completeness check of finding 18 has an answer other than silence for every file the template carries (automation-gates.md \u00a75)",
|
|
112
112
|
"allowed": [
|
|
113
113
|
"Dockerfile",
|
|
114
114
|
"index.js",
|
|
@@ -446,7 +446,7 @@
|
|
|
446
446
|
"from": { "path": "api/config/shared-env.json", "text": true },
|
|
447
447
|
"severity": "deploy",
|
|
448
448
|
"owner": "BIZ-general",
|
|
449
|
-
"why": "the shared key set is one fact with one owner; measured on 2026-09-09, none of the nine copies matched the platform file and one key existed in no copy at all. The owner is api/config/shared-env.json and this row renders from it with the renderer the sync command writes with, so a service cannot be synced and reported drifted in the same hour; a run that cannot
|
|
449
|
+
"why": "the shared key set is one fact with one owner; measured on 2026-09-09, none of the nine copies matched the platform file and one key existed in no copy at all. The owner is api/config/shared-env.json and this row renders from it with the renderer the sync command writes with, so a service cannot be synced and reported drifted in the same hour; a run that cannot REACH the workspace says NOT RUN and names the command, because a render published with this package answers about the day it was published, not about the SSOT; a run that does reach it and finds no api/config/shared-env.json in the api checkout stops instead - a missing SSOT in a workspace the run can read is a finding and never a NOT RUN (automation-gates.md §5), and the message names the checkout to update and the api ref a CI job has to move",
|
|
450
450
|
"fix": "npx oa-sync-template shared-env --target .",
|
|
451
451
|
"doc": "api/docs/governance/confirmations/biz-service-manifest.md \u00a718"
|
|
452
452
|
},
|
|
@@ -497,6 +497,20 @@
|
|
|
497
497
|
"fix": "in docker-compose.yml set the service block's command to [\"node\", \"index.js\"] (the entrypoint init.sh execs it, so node becomes PID 1), then recreate the container and smoke it",
|
|
498
498
|
"doc": "api/docs/governance/confirmations/biz-compose-pid1.md"
|
|
499
499
|
},
|
|
500
|
+
{
|
|
501
|
+
"id": "R-USER",
|
|
502
|
+
"check": "process-identity",
|
|
503
|
+
"image_path": "Dockerfile",
|
|
504
|
+
"image_stage": "production",
|
|
505
|
+
"path": "docker-compose.yml",
|
|
506
|
+
"service_user": "node",
|
|
507
|
+
"service_uid": "1000:1000",
|
|
508
|
+
"severity": "deploy",
|
|
509
|
+
"owner": "BIZ-general",
|
|
510
|
+
"why": "WHICH USER the process is, which is a platform fact and not a service build. Measured 2026-09-17 on a production image built from the platform template: `docker run --rm <image> id` answered uid=0(root) gid=0(root), while the dev container beside it ran as 1000:1000 because docker-compose.yml pins it - nobody had decided that difference, the production stage simply declared no USER, and nothing in the uniform could say so because Dockerfile is a file of the own class. This row is the SECOND platform fact carved out of that class, for the same reason as the first (R-NODE, the node major): what a service BUILDS is its own, what it RUNS AS is the platform. It measures the two places nothing else holds - the production stage of the Dockerfile, which is the stage CI builds and the one the defect lived in, and the SERVICE node of the dev compose. The runner user: sits inside the block F-RUNNER holds byte for byte, and the production compose is the whole-file render of G-PROD, so neither is repeated here (change-discipline.md, One rail per concern). The two spellings are ONE identity, and the row carries both because no machine resolves an account name to a uid without the image: node is uid 1000, gid 1000 in the node images this platform builds on, measured docker run --rm node:24-alpine id node",
|
|
511
|
+
"fix": "in the production stage of Dockerfile put RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app, then USER node before npm ci, and --chown=node:node on both COPY lines; in docker-compose.yml give the service node the line user: \"1000:1000\"",
|
|
512
|
+
"doc": "api/docs/biz/00-model/service-shape.md"
|
|
513
|
+
},
|
|
500
514
|
{
|
|
501
515
|
"id": "R-PORTS-DEV",
|
|
502
516
|
"check": "compose-no-ports",
|
|
@@ -587,6 +601,19 @@
|
|
|
587
601
|
"fix": "set DB_USER in this service's env template to oagen_<shortname>, where <shortname> is what config/service/config.json declares; the account and its grants are created by the operator installing the schema",
|
|
588
602
|
"doc": "api/docs/governance/confirmations/db-accounts-per-service.md"
|
|
589
603
|
},
|
|
604
|
+
{
|
|
605
|
+
"id": "D-DB-CI-ACCOUNT",
|
|
606
|
+
"check": "db-ci-account",
|
|
607
|
+
"path": ".gitlab-ci.yml",
|
|
608
|
+
"block": "oa-ci v1",
|
|
609
|
+
"key": "DB_USER",
|
|
610
|
+
"account": "root",
|
|
611
|
+
"severity": "deploy",
|
|
612
|
+
"owner": "BIZ-general",
|
|
613
|
+
"why": "D-DB-ACCOUNT one row up measures the DECLARATION - the account production installs. Nothing measured the ONE environment where that migration set is applied every day. Measured 2026-09-16 over the eight biz repositories: seven set DB_USER: \"root\" in their test job while declaring oagen_<service> in the env template, so CI proved the set under rights no production box grants - the gap db-migrations-first-deploy 001 names (\"migrace nikdy pod rootem\") in the only place a migration can be tried before a deploy. A separate row and not a wider D-DB-ACCOUNT: another file, another fix, and one row with two fixes is two mechanisms under one name (automation-gates.md §1.2). It reads the lines OUTSIDE the oa-ci v1 block, because that block is the platform's and G-CI compares it byte for byte; reading a region and not the whole file is also what keeps it clear of the defect that had the whole-file row over .gitlab-ci.yml withdrawn after three days. It asks WHO the database steps run as and deliberately not WHETHER a repository runs them: a job that builds no schema has no account for the step to create, and demanding one would be this row inventing a rule (truth-over-agreement.md §6). Silent for a service with no database block by construction - pdfgen is the live case",
|
|
614
|
+
"fix": "in the test job of .gitlab-ci.yml set DB_USER to the account config/env-templates/<service>.env declares, give DB_PASSWORD a throwaway value of the job, name the sidecar's root credential CI_DB_ROOT_USER / CI_DB_ROOT_PASSWORD, and run `npx oa-biz-ci-gate setup-db-account` as the first before_script step, before ci:gate:setup",
|
|
615
|
+
"doc": "api/docs/governance/confirmations/db-accounts-per-service.md"
|
|
616
|
+
},
|
|
590
617
|
{
|
|
591
618
|
"id": "D-DB-HEADERS",
|
|
592
619
|
"check": "install-contract",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-validator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
4
|
"description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
|
|
5
5
|
"oa": {
|
|
6
6
|
"category": "orchestration"
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"author": "OnlineApps",
|
|
29
29
|
"license": "PROPRIETARY",
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"@onlineapps/cookbook-core": "6.0.0",
|
|
31
32
|
"@onlineapps/logger-contract": "2.0.0",
|
|
32
33
|
"@onlineapps/service-validator-core": "2.1.0"
|
|
33
34
|
},
|
|
@@ -10,6 +10,8 @@ const {
|
|
|
10
10
|
describeStepFailure, describeStepIdentity, KIND_STEP, KIND_COOKBOOK_LOAD_FAILURE
|
|
11
11
|
} = require('./utils/stepFailure');
|
|
12
12
|
const { parseHandlerRef, resolveHandlerModule } = require('./utils/handlerRef');
|
|
13
|
+
const { createRunContext, resolveStepInput, recordStepOutcome, checkStepInputExpressions }
|
|
14
|
+
= require('./utils/stepReferences');
|
|
13
15
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -168,6 +170,17 @@ class CookbookTestRunner {
|
|
|
168
170
|
// along, so the catching layer still sees the original error object.
|
|
169
171
|
try {
|
|
170
172
|
this.validateCookbook(cookbookData);
|
|
173
|
+
|
|
174
|
+
// What the recipe WRITES, before anything of it runs. A helper call and a
|
|
175
|
+
// `{{steps.<id>}}` naming no step of this cookbook are both refused here:
|
|
176
|
+
// production evaluates the first and refuses the second at submission, so
|
|
177
|
+
// letting either through as literal text would make a green Tier-1 say
|
|
178
|
+
// more than production will (utils/stepReferences.js). The step values
|
|
179
|
+
// themselves are resolved per step, later and against what ran.
|
|
180
|
+
const referenceProblem = await checkStepInputExpressions(readCookbookSteps(cookbookData.steps));
|
|
181
|
+
if (referenceProblem !== null) {
|
|
182
|
+
throw new Error(`[CookbookTestRunner] ${referenceProblem}`);
|
|
183
|
+
}
|
|
171
184
|
} catch (error) {
|
|
172
185
|
if (typeof cookbook === 'string') {
|
|
173
186
|
throw new Error(`${error.message} (cookbook file: ${cookbook})`, { cause: error });
|
|
@@ -190,8 +203,19 @@ class CookbookTestRunner {
|
|
|
190
203
|
// Execute steps
|
|
191
204
|
const cookbookName = cookbookData.description || 'Unnamed';
|
|
192
205
|
const stepResults = [];
|
|
206
|
+
// What the steps of THIS cookbook have produced so far — the scope a
|
|
207
|
+
// `{{steps.<step_id>.output.…}}` reference resolves against
|
|
208
|
+
// (src/utils/stepReferences.js). It is built here, per cookbook, because
|
|
209
|
+
// `runCookbooks()` drives many cookbooks through one runner and a step of
|
|
210
|
+
// one must never see a step of another.
|
|
211
|
+
const runContext = createRunContext();
|
|
193
212
|
for (const [stepIndex, step] of stepsArray.entries()) {
|
|
194
|
-
const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults);
|
|
213
|
+
const stepResult = await this.executeStep(step, testConfig, stepIndex, cookbookData.defaults, runContext);
|
|
214
|
+
// Every step is recorded — passed or failed. `recordStepOutcome` decides
|
|
215
|
+
// what an entry carries; a failed step keeps its status and no output, so
|
|
216
|
+
// a reference into it stays literal (the section "Runtime context
|
|
217
|
+
// (`context.steps`)" of api/docs/biz/40-cookbooks/format.md).
|
|
218
|
+
recordStepOutcome(runContext, step, stepResult);
|
|
195
219
|
// Which cookbook this step came from. Without it a failed step in the
|
|
196
220
|
// aggregate is an id with no file behind it, and a service can carry a
|
|
197
221
|
// dozen cookbooks (utils/stepFailure.js).
|
|
@@ -385,9 +409,21 @@ class CookbookTestRunner {
|
|
|
385
409
|
}
|
|
386
410
|
|
|
387
411
|
/**
|
|
388
|
-
* Execute single step
|
|
412
|
+
* Execute single step.
|
|
413
|
+
*
|
|
414
|
+
* @param {Object} step - the cookbook step as written
|
|
415
|
+
* @param {Object} testConfig - the cookbook's `test` block
|
|
416
|
+
* @param {number} [stepIndex] - the step's position, for messages
|
|
417
|
+
* @param {Object|null} [cookbookDefaults] - the cookbook's `defaults` block
|
|
418
|
+
* @param {{steps: Object}} [runContext] - what earlier steps of this cookbook
|
|
419
|
+
* produced (`utils/stepReferences`). A step executed on its own has no
|
|
420
|
+
* earlier steps, so the default is an EMPTY context — the accurate
|
|
421
|
+
* value for that case, not a stand-in for a missing one: every
|
|
422
|
+
* `{{steps.…}}` reference then resolves to nothing and survives as
|
|
423
|
+
* literal text, exactly as the format prescribes.
|
|
424
|
+
* @returns {Promise<Object>} the step result record
|
|
389
425
|
*/
|
|
390
|
-
async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null) {
|
|
426
|
+
async executeStep(step, testConfig, stepIndex = 0, cookbookDefaults = null, runContext = createRunContext()) {
|
|
391
427
|
const startTime = Date.now();
|
|
392
428
|
|
|
393
429
|
// ONE label for this step, resolved once. `step.id` alone was undefined for
|
|
@@ -460,8 +496,16 @@ class CookbookTestRunner {
|
|
|
460
496
|
// { modulePath, exportName } (v3 handler dispatch is the only mode).
|
|
461
497
|
const spec = await this.resolveOperation(step.service, step.operation);
|
|
462
498
|
|
|
499
|
+
// WHAT THE HANDLER IS ACTUALLY CALLED WITH. The step's `input` is the
|
|
500
|
+
// cookbook's text; a `{{steps.<step_id>.output.…}}` reference in it is
|
|
501
|
+
// resolved HERE, against what the earlier steps of this cookbook returned.
|
|
502
|
+
// Inside the try, so an evaluator error becomes this step's failure and
|
|
503
|
+
// not the whole run's.
|
|
504
|
+
const input = await resolveStepInput(step, runContext);
|
|
505
|
+
|
|
463
506
|
await this._dispatchViaHandler({
|
|
464
507
|
step,
|
|
508
|
+
input,
|
|
465
509
|
spec,
|
|
466
510
|
testConfig,
|
|
467
511
|
validationTenantId,
|
|
@@ -562,7 +606,7 @@ class CookbookTestRunner {
|
|
|
562
606
|
* all connector slots set to null — handlers that need DB access use
|
|
563
607
|
* direct sequelize import per biz/60-templates/onboarding-checklist.md §9a.
|
|
564
608
|
*/
|
|
565
|
-
async _dispatchViaHandler({ step, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
|
|
609
|
+
async _dispatchViaHandler({ step, input, spec, testConfig, validationTenantId, validationWorkspaceId, result, startTime }) {
|
|
566
610
|
const stepTimeout = testConfig.timeout || this.timeout;
|
|
567
611
|
const correlationId = step.correlation_id || crypto.randomUUID();
|
|
568
612
|
// Production ctx (from ContextBuilder) always carries workflow_id + person_id.
|
|
@@ -619,7 +663,7 @@ class CookbookTestRunner {
|
|
|
619
663
|
operation: step.operation,
|
|
620
664
|
module: spec.modulePath,
|
|
621
665
|
export: spec.exportName,
|
|
622
|
-
input
|
|
666
|
+
input,
|
|
623
667
|
ctx: {
|
|
624
668
|
tenant_id: ctx.tenant_id,
|
|
625
669
|
workspace_id: ctx.workspace_id,
|
|
@@ -656,7 +700,7 @@ class CookbookTestRunner {
|
|
|
656
700
|
let handlerOutput = null;
|
|
657
701
|
try {
|
|
658
702
|
handlerOutput = await Promise.race([
|
|
659
|
-
Promise.resolve().then(() => handlerFn(
|
|
703
|
+
Promise.resolve().then(() => handlerFn(input || {}, ctx)),
|
|
660
704
|
timeoutPromise
|
|
661
705
|
]);
|
|
662
706
|
} catch (error) {
|
|
@@ -25,6 +25,7 @@ const { runManifest } = require('./manifest/runManifest');
|
|
|
25
25
|
const { resolveWorkspaceRoot } = require('./manifest/workspaceRoot');
|
|
26
26
|
const { renderBanner, describeFinding } = require('./manifest/report');
|
|
27
27
|
const { buildDeployabilitySignal, writeDeployabilitySignal } = require('./manifest/deployabilitySignal');
|
|
28
|
+
const { isGitCheckout, NOT_A_CHECKOUT } = require('./manifest/gitCheckout');
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The manifest rows step 2 answers: the configuration documents a biz service is
|
|
@@ -76,6 +77,59 @@ function readStepMeasurement(step, field, stepName) {
|
|
|
76
77
|
return value;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The step that produced a finding, carried ON the finding.
|
|
82
|
+
*
|
|
83
|
+
* `results.errors` is one flat array: step 1 pushes objects
|
|
84
|
+
* `{ type, path, message, fix }` and steps 2-7 push sentences they have already
|
|
85
|
+
* written in the `[Context] Problem - Expected/Fix` shape. Nothing in it said
|
|
86
|
+
* WHICH step a finding came from, so the only reader that needs to know — the
|
|
87
|
+
* wrapper, which retries a transient step and refuses a static one — had to read
|
|
88
|
+
* `results.steps` instead and could say nothing about an individual finding.
|
|
89
|
+
*
|
|
90
|
+
* The marker is ADDED, never substituted: a sentence stays the same sentence and
|
|
91
|
+
* an object keeps every field it had, because `describeValidationFinding()` of
|
|
92
|
+
* `@onlineapps/service-wrapper` 9.0.x renders a string verbatim and an object
|
|
93
|
+
* only when it carries `type` and `message` — anything else reaches the operator
|
|
94
|
+
* as raw JSON. Rewriting the sentences into `{ step, message }` objects would
|
|
95
|
+
* therefore break the released wrapper, so the sentences keep their shape until
|
|
96
|
+
* a wrapper that reads `step` is published (d.602; the report to BIZ-general).
|
|
97
|
+
*
|
|
98
|
+
* The findings are COPIED rather than stamped in place: the step's own
|
|
99
|
+
* `results.steps.<step>.errors` is its answer and stays untouched.
|
|
100
|
+
*
|
|
101
|
+
* @param {Array<(string|object)>} findings what a step reported
|
|
102
|
+
* @param {string} step the step's key in `results.steps`
|
|
103
|
+
* @returns {Array<(string|object)>} the same findings, objects carrying `step`
|
|
104
|
+
*/
|
|
105
|
+
function attributeToStep(findings, step) {
|
|
106
|
+
if (!Array.isArray(findings)) {
|
|
107
|
+
// The TypeError this replaces reported itself instead of the finding: step 6
|
|
108
|
+
// spread a field the step does not have and aborted the whole run with
|
|
109
|
+
// "results.steps.connectors.warnings is not iterable" (F12, 2026-08-22).
|
|
110
|
+
throw new Error(`[ValidationOrchestrator] Step "${step}" reported findings that are not an array - `
|
|
111
|
+
+ `got ${JSON.stringify(findings)}, expected an array. `
|
|
112
|
+
+ `Fix: return "errors" as an array from step "${step}".`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return findings.map((finding) => (
|
|
116
|
+
finding !== null && typeof finding === 'object' ? { ...finding, step } : finding
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The finding of a step whose body THREW — an object, so that it carries `step`,
|
|
122
|
+
* and with `type` so the released wrapper renders it as a sentence rather than
|
|
123
|
+
* as JSON.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} step the step's key in `results.steps`
|
|
126
|
+
* @param {string} message the failure, in the `[Context] Problem - Fix` shape
|
|
127
|
+
* @returns {{type: string, step: string, message: string}}
|
|
128
|
+
*/
|
|
129
|
+
function stepThrewFinding(step, message) {
|
|
130
|
+
return { type: 'STEP_THREW', step, message };
|
|
131
|
+
}
|
|
132
|
+
|
|
79
133
|
/**
|
|
80
134
|
* ValidationOrchestrator
|
|
81
135
|
*
|
|
@@ -383,22 +437,27 @@ class ValidationOrchestrator {
|
|
|
383
437
|
|
|
384
438
|
try {
|
|
385
439
|
// Step 1: Service Structure
|
|
386
|
-
this.
|
|
387
|
-
|
|
388
|
-
|
|
440
|
+
const structureFailed = await this.runStep(results, 'structure', async () => {
|
|
441
|
+
this.logger.info('[ValidationOrchestrator] Step 1/7: Service Structure');
|
|
442
|
+
results.steps.structure = await this.validateStructure();
|
|
443
|
+
if (results.steps.structure.valid) return false;
|
|
444
|
+
|
|
389
445
|
results.success = false;
|
|
390
|
-
results.errors.push(...results.steps.structure.errors);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
446
|
+
results.errors.push(...attributeToStep(results.steps.structure.errors, 'structure'));
|
|
447
|
+
return true;
|
|
448
|
+
});
|
|
449
|
+
// Fail fast - can't continue without proper structure
|
|
450
|
+
if (structureFailed) return this.finalizeResults(results, startTime);
|
|
394
451
|
|
|
395
452
|
// Step 2: Config Files
|
|
396
|
-
this.
|
|
397
|
-
|
|
398
|
-
|
|
453
|
+
await this.runStep(results, 'config', async () => {
|
|
454
|
+
this.logger.info('[ValidationOrchestrator] Step 2/7: Config Files');
|
|
455
|
+
results.steps.config = await this.validateConfig();
|
|
456
|
+
if (results.steps.config.valid) return;
|
|
457
|
+
|
|
399
458
|
results.success = false;
|
|
400
|
-
results.errors.push(...results.steps.config.errors);
|
|
401
|
-
}
|
|
459
|
+
results.errors.push(...attributeToStep(results.steps.config.errors, 'config'));
|
|
460
|
+
});
|
|
402
461
|
|
|
403
462
|
// Step 3: Environment Contract
|
|
404
463
|
//
|
|
@@ -407,42 +466,53 @@ class ValidationOrchestrator {
|
|
|
407
466
|
// start without must fail here, where its name and the reason it exists
|
|
408
467
|
// are both at hand. SECRETS_MASTER_KEY used to fail after MQ
|
|
409
468
|
// registration, several layers away from the declaration (defect D2).
|
|
410
|
-
this.
|
|
411
|
-
|
|
412
|
-
|
|
469
|
+
const envFailed = await this.runStep(results, 'env', () => {
|
|
470
|
+
this.logger.info('[ValidationOrchestrator] Step 3/7: Environment Contract');
|
|
471
|
+
results.steps.env = this.validateEnvContract();
|
|
472
|
+
if (results.steps.env.valid) return false;
|
|
473
|
+
|
|
413
474
|
results.success = false;
|
|
414
|
-
results.errors.push(...results.steps.env.errors);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
475
|
+
results.errors.push(...attributeToStep(results.steps.env.errors, 'env'));
|
|
476
|
+
return true;
|
|
477
|
+
});
|
|
478
|
+
// Fail fast — running handlers without the environment they declared
|
|
479
|
+
// produces a second, less honest error somewhere further in.
|
|
480
|
+
if (envFailed) return this.finalizeResults(results, startTime);
|
|
419
481
|
|
|
420
482
|
// Step 4: Operations Compliance
|
|
421
|
-
this.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
483
|
+
await this.runStep(results, 'operations', async () => {
|
|
484
|
+
this.logger.info('[ValidationOrchestrator] Step 4/7: Operations Compliance');
|
|
485
|
+
results.steps.operations = await this.validateOperations();
|
|
486
|
+
results.warnings.push(...attributeToStep(results.steps.operations.warnings || [], 'operations'));
|
|
487
|
+
if (results.steps.operations.valid) return;
|
|
488
|
+
|
|
425
489
|
results.success = false;
|
|
426
|
-
results.errors.push(...results.steps.operations.errors);
|
|
427
|
-
}
|
|
490
|
+
results.errors.push(...attributeToStep(results.steps.operations.errors, 'operations'));
|
|
491
|
+
});
|
|
428
492
|
|
|
429
493
|
// Step 5: Cookbook Tests
|
|
430
|
-
this.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
494
|
+
await this.runStep(results, 'cookbooks', async () => {
|
|
495
|
+
this.logger.info('[ValidationOrchestrator] Step 5/7: Cookbook Tests');
|
|
496
|
+
results.steps.cookbooks = await this.runCookbookTests();
|
|
497
|
+
// Measured values only — see readStepMeasurement() for why `|| 0` here
|
|
498
|
+
// was the reason the generator's refusal never fired. The refusal now
|
|
499
|
+
// fails THIS step by name instead of surfacing as a step-less
|
|
500
|
+
// "Validation error" (d.602).
|
|
501
|
+
results.totalTests += readStepMeasurement(results.steps.cookbooks, 'total', 'cookbooks');
|
|
502
|
+
results.passedTests += readStepMeasurement(results.steps.cookbooks, 'passed', 'cookbooks');
|
|
503
|
+
results.failedTests += readStepMeasurement(results.steps.cookbooks, 'failed', 'cookbooks');
|
|
504
|
+
if (results.steps.cookbooks.success) return;
|
|
505
|
+
|
|
438
506
|
results.success = false;
|
|
439
|
-
results.errors.push(...(results.steps.cookbooks.errors || []));
|
|
440
|
-
}
|
|
507
|
+
results.errors.push(...attributeToStep(results.steps.cookbooks.errors || [], 'cookbooks'));
|
|
508
|
+
});
|
|
441
509
|
|
|
442
510
|
// Step 6: Connector Integration
|
|
443
|
-
this.
|
|
444
|
-
|
|
445
|
-
|
|
511
|
+
await this.runStep(results, 'connectors', () => {
|
|
512
|
+
this.logger.info('[ValidationOrchestrator] Step 6/7: Connector Integration');
|
|
513
|
+
results.steps.connectors = this.validateConnectors();
|
|
514
|
+
if (results.steps.connectors.valid) return;
|
|
515
|
+
|
|
446
516
|
// Severity is unchanged: the connector contract stays non-critical and
|
|
447
517
|
// its findings are reported as warnings, not as validation errors.
|
|
448
518
|
//
|
|
@@ -453,8 +523,8 @@ class ValidationOrchestrator {
|
|
|
453
523
|
// — threw "results.steps.connectors.warnings is not iterable", aborted
|
|
454
524
|
// the whole run, and reported that TypeError in place of every finding.
|
|
455
525
|
// Reproduced against biz-pdfgen and biz-property on 2026-08-22.
|
|
456
|
-
results.warnings.push(...results.steps.connectors.errors);
|
|
457
|
-
}
|
|
526
|
+
results.warnings.push(...attributeToStep(results.steps.connectors.errors, 'connectors'));
|
|
527
|
+
});
|
|
458
528
|
|
|
459
529
|
// Step 7: Manifest conformance
|
|
460
530
|
//
|
|
@@ -467,15 +537,17 @@ class ValidationOrchestrator {
|
|
|
467
537
|
// the service running and marks it undeployable — the banner says so at
|
|
468
538
|
// the end of this log and `ci/deployability.json` says so to
|
|
469
539
|
// `deploy-production`.
|
|
470
|
-
this.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
540
|
+
await this.runStep(results, 'manifest', () => {
|
|
541
|
+
this.logger.info('[ValidationOrchestrator] Step 7/7: Manifest conformance');
|
|
542
|
+
results.steps.manifest = this.validateManifestConformance();
|
|
543
|
+
results.deployable = results.steps.manifest.deployable;
|
|
544
|
+
results.deployFindings = results.steps.manifest.deployFindings;
|
|
545
|
+
results.notRun = results.steps.manifest.notRun;
|
|
546
|
+
if (results.steps.manifest.valid) return;
|
|
547
|
+
|
|
476
548
|
results.success = false;
|
|
477
|
-
results.errors.push(...results.steps.manifest.errors);
|
|
478
|
-
}
|
|
549
|
+
results.errors.push(...attributeToStep(results.steps.manifest.errors, 'manifest'));
|
|
550
|
+
});
|
|
479
551
|
|
|
480
552
|
// Finalize and generate proof if successful
|
|
481
553
|
return await this.finalizeResults(results, startTime);
|
|
@@ -483,11 +555,74 @@ class ValidationOrchestrator {
|
|
|
483
555
|
} catch (error) {
|
|
484
556
|
this.logger.error(`[ValidationOrchestrator] Validation failed: ${error.message}`);
|
|
485
557
|
results.success = false;
|
|
486
|
-
|
|
558
|
+
// A failure that belongs to NO step — `finalizeResults` is the only code
|
|
559
|
+
// left out here. Everything raised inside a step is already recorded under
|
|
560
|
+
// that step's name by runStep(), which marks the error on its way out, so
|
|
561
|
+
// adding this sentence as well would report one failure twice and the
|
|
562
|
+
// second copy would carry no step at all (d.602).
|
|
563
|
+
if (typeof error.validationStep !== 'string') {
|
|
564
|
+
results.errors.push(`Validation error: ${error.message}`);
|
|
565
|
+
}
|
|
487
566
|
return this.finalizeResults(results, startTime);
|
|
488
567
|
}
|
|
489
568
|
}
|
|
490
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Run one step of the validation and GUARANTEE its record in `results.steps`.
|
|
572
|
+
*
|
|
573
|
+
* Each step method catches what its own work raises and answers
|
|
574
|
+
* `{ valid: false, … }` by its own name. This is the net under all of them: an
|
|
575
|
+
* exception from the logger, from the aggregate that reads a step's
|
|
576
|
+
* measurements, or from a step's findings being something other than an array
|
|
577
|
+
* used to escape to the catch of runFullValidation(), which pushed
|
|
578
|
+
* `Validation error: <message>` and left `results.steps.<step>` ABSENT.
|
|
579
|
+
*
|
|
580
|
+
* That absence is not a cosmetic gap. `@onlineapps/service-wrapper` 9.0.x
|
|
581
|
+
* decides whether a failed FÁZE 0.2 may be retried by reading
|
|
582
|
+
* `result.steps.<step>.valid` (its `STATIC_VALIDATION_STEPS`), and a step with
|
|
583
|
+
* no record reads as "the validator threw" — transient — so a structural
|
|
584
|
+
* defect bought the full six-attempt budget: 18,5 minutes of a boot that
|
|
585
|
+
* cannot succeed (measured on biz-converter, W591).
|
|
586
|
+
*
|
|
587
|
+
* The run still STOPS where it stopped before: the error is re-thrown, marked,
|
|
588
|
+
* and the outer catch finalizes exactly as it did.
|
|
589
|
+
*
|
|
590
|
+
* @param {object} results the run being assembled
|
|
591
|
+
* @param {string} step the step's key in `results.steps`
|
|
592
|
+
* @param {function(): *} block the step, its harvest included
|
|
593
|
+
* @returns {Promise<*>} whatever the block returned
|
|
594
|
+
*/
|
|
595
|
+
async runStep(results, step, block) {
|
|
596
|
+
try {
|
|
597
|
+
return await block();
|
|
598
|
+
} catch (error) {
|
|
599
|
+
const finding = stepThrewFinding(step,
|
|
600
|
+
`[ValidationOrchestrator] Step "${step}" threw - ${error.message} `
|
|
601
|
+
+ `Fix: repair what that message names; step "${step}" reached no verdict of its own.`);
|
|
602
|
+
|
|
603
|
+
// What the step had already measured is KEPT: a throw during the harvest
|
|
604
|
+
// must not erase the counts or findings the step did produce.
|
|
605
|
+
const measured = results.steps[step];
|
|
606
|
+
const partial = measured !== null && typeof measured === 'object' ? measured : {};
|
|
607
|
+
|
|
608
|
+
results.steps[step] = {
|
|
609
|
+
...partial,
|
|
610
|
+
// Both fields, because this net does not know which one the step
|
|
611
|
+
// answers with: steps 1-4, 6 and 7 report `valid`, step 5 reports
|
|
612
|
+
// `success`, and a reader of either must see the failure.
|
|
613
|
+
valid: false,
|
|
614
|
+
success: false,
|
|
615
|
+
threw: true,
|
|
616
|
+
errors: [...(Array.isArray(partial.errors) ? partial.errors : []), finding]
|
|
617
|
+
};
|
|
618
|
+
results.success = false;
|
|
619
|
+
results.errors.push(finding);
|
|
620
|
+
|
|
621
|
+
error.validationStep = step;
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
491
626
|
/**
|
|
492
627
|
* Step 1: Validate service structure
|
|
493
628
|
*/
|
|
@@ -500,7 +635,8 @@ class ValidationOrchestrator {
|
|
|
500
635
|
} catch (error) {
|
|
501
636
|
return {
|
|
502
637
|
valid: false,
|
|
503
|
-
|
|
638
|
+
threw: true,
|
|
639
|
+
errors: [stepThrewFinding('structure', `Structure validation failed: ${error.message}`)]
|
|
504
640
|
};
|
|
505
641
|
}
|
|
506
642
|
}
|
|
@@ -539,7 +675,8 @@ class ValidationOrchestrator {
|
|
|
539
675
|
this.logger.error(`[ValidationOrchestrator] Step 2/7 could not run: ${error.message}`);
|
|
540
676
|
return {
|
|
541
677
|
valid: false,
|
|
542
|
-
|
|
678
|
+
threw: true,
|
|
679
|
+
errors: [stepThrewFinding('config', error.message)],
|
|
543
680
|
findings: []
|
|
544
681
|
};
|
|
545
682
|
}
|
|
@@ -622,7 +759,8 @@ class ValidationOrchestrator {
|
|
|
622
759
|
} catch (error) {
|
|
623
760
|
return {
|
|
624
761
|
valid: false,
|
|
625
|
-
|
|
762
|
+
threw: true,
|
|
763
|
+
errors: [stepThrewFinding('operations', `Operations validation failed: ${error.message}`)],
|
|
626
764
|
warnings: []
|
|
627
765
|
};
|
|
628
766
|
}
|
|
@@ -683,11 +821,12 @@ class ValidationOrchestrator {
|
|
|
683
821
|
} catch (error) {
|
|
684
822
|
return {
|
|
685
823
|
success: false,
|
|
824
|
+
threw: true,
|
|
686
825
|
total: 0,
|
|
687
826
|
passed: 0,
|
|
688
827
|
failed: 0,
|
|
689
828
|
cookbooks: { total: 0, passed: 0, failed: 0 },
|
|
690
|
-
errors: [`Cookbook tests failed: ${error.message}`]
|
|
829
|
+
errors: [stepThrewFinding('cookbooks', `Cookbook tests failed: ${error.message}`)]
|
|
691
830
|
};
|
|
692
831
|
}
|
|
693
832
|
}
|
|
@@ -730,7 +869,7 @@ class ValidationOrchestrator {
|
|
|
730
869
|
+ `no ${path.relative(this.serviceRoot, contractFile)} in this service`);
|
|
731
870
|
return { valid: true, skipped: true, errors: [] };
|
|
732
871
|
}
|
|
733
|
-
return { valid: false, errors: [error.message] };
|
|
872
|
+
return { valid: false, threw: true, errors: [stepThrewFinding('env', error.message)] };
|
|
734
873
|
}
|
|
735
874
|
|
|
736
875
|
if (declaration === null) {
|
|
@@ -766,7 +905,12 @@ class ValidationOrchestrator {
|
|
|
766
905
|
config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
767
906
|
requiredConnectors = JSON.parse(fs.readFileSync(contractFile, 'utf8')).requiredConnectors || {};
|
|
768
907
|
} catch (error) {
|
|
769
|
-
return {
|
|
908
|
+
return {
|
|
909
|
+
valid: false,
|
|
910
|
+
threw: true,
|
|
911
|
+
errors: [stepThrewFinding('connectors',
|
|
912
|
+
`[ConnectorContract] Cannot read the connector declarations - ${error.message}`)]
|
|
913
|
+
};
|
|
770
914
|
}
|
|
771
915
|
|
|
772
916
|
const result = verifyConnectorContract({ config, requiredConnectors, env: process.env });
|
|
@@ -788,18 +932,59 @@ class ValidationOrchestrator {
|
|
|
788
932
|
* the signal carries them (`automation-gates.md` §5). There is no switch that
|
|
789
933
|
* skips this step and none that moves the signal: one path, unbypassable.
|
|
790
934
|
*
|
|
935
|
+
* OUTSIDE A GIT CHECKOUT THE STEP DOES NOT MEASURE. Owner decision 2026-09-17
|
|
936
|
+
* (confirmation `biz-service-manifest` 011): a production image is not a
|
|
937
|
+
* checkout and carries neither `docker-compose.yml` nor `README.md`, so the
|
|
938
|
+
* uniform measured there reports absences that say nothing about the
|
|
939
|
+
* repository — 12 findings for a healthy service. A verdict reached that way
|
|
940
|
+
* is not a strict check, it is a wrong answer, and `NOT DEPLOYABLE` printed
|
|
941
|
+
* from it discredits the rows that ARE true. What proves deployability is the
|
|
942
|
+
* CI job `validate-uniform`, from every commit, over the checkout
|
|
943
|
+
* (confirmation 010); a running container cannot prove it and no longer
|
|
944
|
+
* pretends to. The step then says NOT RUN in one line and `deployable` stays
|
|
945
|
+
* `null` — NOT MEASURED, which is a different statement from `false` and is
|
|
946
|
+
* why that field has three states.
|
|
947
|
+
*
|
|
791
948
|
* Order inside the step is deliberate: the banner is printed BEFORE the file
|
|
792
949
|
* is written, so a service root that cannot be written to still leaves the
|
|
793
950
|
* human-readable verdict in the log.
|
|
794
951
|
*
|
|
795
952
|
* @see api/docs/governance/confirmations/biz-service-manifest.md §4
|
|
796
|
-
* @returns {{valid: boolean, deployable: boolean, errors: string[], findings: object[],
|
|
953
|
+
* @returns {{valid: boolean, deployable: (boolean|null), errors: string[], findings: object[],
|
|
797
954
|
* deployFindings: object[], notRun: object[], signalPath: string|null}}
|
|
798
955
|
*/
|
|
799
956
|
validateManifestConformance() {
|
|
800
957
|
try {
|
|
958
|
+
// The run happens first and costs nothing extra: step 2 has already asked
|
|
959
|
+
// for it (`manifestRun()` is computed once per validation), and a service
|
|
960
|
+
// root that vanished must still fail by its own name rather than be
|
|
961
|
+
// reported as "not a checkout".
|
|
801
962
|
const result = this.manifestRun();
|
|
802
963
|
|
|
964
|
+
if (!isGitCheckout(this.serviceRoot)) {
|
|
965
|
+
// One line, and no table: naming the findings of a tree this run cannot
|
|
966
|
+
// read would be the false guarantee of `automation-gates.md` §5 in
|
|
967
|
+
// reverse — a reader acting on `README.md missing` inside a container
|
|
968
|
+
// would go looking for a file the image is not supposed to carry.
|
|
969
|
+
this.logger.info(`[ValidationOrchestrator] NOT RUN manifest conformance — ${NOT_A_CHECKOUT} `
|
|
970
|
+
+ 'Deployability is proven per commit by the CI job `validate-uniform` over the checkout '
|
|
971
|
+
+ '(confirmation biz-service-manifest 010).');
|
|
972
|
+
|
|
973
|
+
// No signal either: `ci/deployability.json` IS a verdict, and this run
|
|
974
|
+
// reached none. Writing one would hand `deploy-production` a file that
|
|
975
|
+
// looks like an answer. The checkout path writes it
|
|
976
|
+
// (`src/cli/oa-validate.js`), which is where the answer exists.
|
|
977
|
+
return {
|
|
978
|
+
valid: true,
|
|
979
|
+
deployable: null,
|
|
980
|
+
errors: [],
|
|
981
|
+
findings: [],
|
|
982
|
+
deployFindings: [],
|
|
983
|
+
notRun: [],
|
|
984
|
+
signalPath: null
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
803
988
|
// The banner and `ci/deployability.json` describe the WHOLE uniform,
|
|
804
989
|
// CONFIG_STEP_ROWS included: the verdict is about the tree, and a reader
|
|
805
990
|
// (or `deploy-production`) must not conclude it is clean because the boot
|
|
@@ -844,8 +1029,9 @@ class ValidationOrchestrator {
|
|
|
844
1029
|
this.logger.error(`[ValidationOrchestrator] Step 7/7 could not run: ${error.message}`);
|
|
845
1030
|
return {
|
|
846
1031
|
valid: false,
|
|
1032
|
+
threw: true,
|
|
847
1033
|
deployable: false,
|
|
848
|
-
errors: [error.message],
|
|
1034
|
+
errors: [stepThrewFinding('manifest', error.message)],
|
|
849
1035
|
findings: [],
|
|
850
1036
|
deployFindings: [],
|
|
851
1037
|
notRun: [],
|