@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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { resolveReferencesWith, resolveReferencePath } = require('@onlineapps/cookbook-core');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The run context a Tier-1 cookbook resolves its `{{…}}` references against.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT EXISTS. Until 2026-09-17 the runner executed every step independently:
|
|
9
|
+
* `stepResults` only counted, and nothing a step returned could reach the next
|
|
10
|
+
* one. A recipe therefore could not build the data it needs, so biz-converter's
|
|
11
|
+
* boot recipe depended on a row only a TEST_ONLY seed supplies — and on
|
|
12
|
+
* production Tier-1 answered `NOT_CONVERTIBLE`, the boot exited 1 after ~18
|
|
13
|
+
* minutes of backoff and the container restarted in a loop (measured
|
|
14
|
+
* 2026-09-16). The owner's decision was that the recipe stays as written and the
|
|
15
|
+
* RUNNER learns what the orchestrator already does
|
|
16
|
+
* (`api/docs/governance/confirmations/converter-boot-cookbook.md` 001).
|
|
17
|
+
*
|
|
18
|
+
* ONE RAIL, AND IT IS NOT THIS PACKAGE'S. The syntax, the type rule for a whole
|
|
19
|
+
* expression, the ban on addressing a step by position and the fate of an
|
|
20
|
+
* unresolved reference all belong to the cookbook format —
|
|
21
|
+
* `api/docs/biz/40-cookbooks/variable-references.md`. The traversal of the value
|
|
22
|
+
* and the walk of one path belong to `@onlineapps/cookbook-core`:
|
|
23
|
+
* `resolveReferencesWith()` and `resolveReferencePath()`, the same two functions
|
|
24
|
+
* `WorkflowOrchestrator._resolveInputReferencesAsync` /
|
|
25
|
+
* `_resolveTemplateExpression` call. This module writes no parser; it supplies
|
|
26
|
+
* the SCOPE, which is the only part Tier-1 owns.
|
|
27
|
+
*
|
|
28
|
+
* THE SCOPE IS THE ORCHESTRATOR'S, SPELLED THE SAME WAY. A completed entry is
|
|
29
|
+
* "the step definition unchanged, plus two added fields: `output` and
|
|
30
|
+
* `_execution`" — the section "Runtime context (`context.steps`)" of
|
|
31
|
+
* api/docs/biz/40-cookbooks/format.md — and a reference addresses it by
|
|
32
|
+
* `step_id`, which is what
|
|
33
|
+
* `WorkflowOrchestrator.referenceScope()` puts under the root `steps` (its
|
|
34
|
+
* `steps_by_id` map). A second spelling here, such as a bare `status` beside
|
|
35
|
+
* `output`, would resolve at boot and stay literal in production: precisely the
|
|
36
|
+
* divergence class that broke the converter's boot.
|
|
37
|
+
*
|
|
38
|
+
* WHAT TIER-1 DELIBERATELY DOES NOT CARRY. The orchestrator's context also holds
|
|
39
|
+
* `api_input`, `delivery` and the tenancy keys. A Tier-1 run has no workflow
|
|
40
|
+
* input and no delivery at all, so those roots are absent and a reference to
|
|
41
|
+
* them stays literal — the norm's documented outcome for an unresolvable
|
|
42
|
+
* reference, not a silent substitution.
|
|
43
|
+
*
|
|
44
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
45
|
+
* @see api/docs/biz/40-cookbooks/format.md
|
|
46
|
+
* @module utils/stepReferences
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The context one cookbook run starts from: no step has produced anything yet.
|
|
51
|
+
*
|
|
52
|
+
* It is per COOKBOOK, never per runner — `runCookbooks()` executes many
|
|
53
|
+
* cookbooks through one runner and a step of one must not see a step of
|
|
54
|
+
* another.
|
|
55
|
+
*
|
|
56
|
+
* @returns {{steps: Object}} the scope `resolveStepInput` resolves against
|
|
57
|
+
*/
|
|
58
|
+
function createRunContext() {
|
|
59
|
+
return { steps: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the `{{…}}` references in ONE step's input.
|
|
64
|
+
*
|
|
65
|
+
* `input` is the only field expanded. The format names three expansion sites —
|
|
66
|
+
* `step.input`, a `foreach` iterator and a `switch` expression
|
|
67
|
+
* (`variable-references.md` § Limitations) — and a Tier-1 cookbook has only the
|
|
68
|
+
* first: `CookbookTestRunner.validateCookbook` requires `service` and
|
|
69
|
+
* `operation` of every step, so a `foreach` or `switch` step never reaches this
|
|
70
|
+
* runner. `expect` is not on that list and is therefore taken literally, the
|
|
71
|
+
* same as `service`, `operation` and `depends_on`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} step - the cookbook step as written
|
|
74
|
+
* @param {{steps: Object}} runContext - what earlier steps produced
|
|
75
|
+
* @returns {Promise<*>} the input the handler is called with
|
|
76
|
+
*/
|
|
77
|
+
function resolveStepInput(step, runContext) {
|
|
78
|
+
return resolveReferencesWith(
|
|
79
|
+
step.input,
|
|
80
|
+
(expression) => resolveReferencePath(expression, runContext)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts.
|
|
86
|
+
*
|
|
87
|
+
* Tier-1 is the service's own startup proof, so a recipe it passes must be a
|
|
88
|
+
* recipe production would accept. Two shapes broke that symmetry, both by going
|
|
89
|
+
* QUIET where production is loud:
|
|
90
|
+
*
|
|
91
|
+
* 1. a helper call — `{{webalizeString(…)}}`, `{{string2file(…)}}`. The
|
|
92
|
+
* orchestrator resolves it through its helper registry and throws on a
|
|
93
|
+
* name the registry does not carry
|
|
94
|
+
* (`WorkflowOrchestrator._executeTemplateHelper`). This runner has no
|
|
95
|
+
* registry — deliberately: a helper reaches its own runtime (ContentResolver,
|
|
96
|
+
* files, storage), which is not what a boot probe may do. So the expression
|
|
97
|
+
* resolved to nothing and survived as literal text, and the step ran with
|
|
98
|
+
* `{{webalizeString(…)}}` where a value belonged.
|
|
99
|
+
* 2. a `{{steps.<id>}}` naming a step the recipe does not define. Production
|
|
100
|
+
* refuses such a cookbook at submission with 400 `Invalid cookbook
|
|
101
|
+
* references` (`infra/api_gateway/shared/cookbookReferences.js`,
|
|
102
|
+
* `api/docs/governance/confirmations/cookbook-validation-placement.md` 001
|
|
103
|
+
* — the receiver's own throw is the safety net for the paths that bypass
|
|
104
|
+
* the gateway, and a Tier-1 boot run is exactly such a path). Here it was a
|
|
105
|
+
* literal too.
|
|
106
|
+
*
|
|
107
|
+
* The literal outcome is right for a reference whose VALUE is missing at that
|
|
108
|
+
* moment — a step that has not run, or one that failed
|
|
109
|
+
* (`variable-references.md` § Unresolved references), and those two stay literal
|
|
110
|
+
* below. It is wrong for a name that does not exist in the recipe at all: no
|
|
111
|
+
* run can ever make it resolve.
|
|
112
|
+
*
|
|
113
|
+
* NO SECOND WALK. The traversal and the expression extraction are the same
|
|
114
|
+
* `resolveReferencesWith()` that resolves the input for real one moment later,
|
|
115
|
+
* so this check sees exactly the expressions the resolver will see. The
|
|
116
|
+
* evaluator returns `undefined` for everything: nothing is substituted here,
|
|
117
|
+
* only read.
|
|
118
|
+
*
|
|
119
|
+
* WHERE THE HELPER-CALL SHAPE OUGHT TO LIVE. `@onlineapps/cookbook-core` owns
|
|
120
|
+
* the format and exports the traversal and the path walk, but no definition of
|
|
121
|
+
* "this expression is a helper call" — the only one on the platform is the
|
|
122
|
+
* private regex in `WorkflowOrchestrator._resolveTemplateExpression`, and this
|
|
123
|
+
* module now holds a second copy of it. That is one concern on two rails
|
|
124
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern) and it belongs
|
|
125
|
+
* beside `resolveReferencePath()` in cookbook-core, where both callers could
|
|
126
|
+
* read it. Recorded for the owner rather than fixed here: cookbook-core is
|
|
127
|
+
* another package's manifest.
|
|
128
|
+
*
|
|
129
|
+
* SCOPE. `input` only, which is the single expansion site a Tier-1 cookbook has
|
|
130
|
+
* (`resolveStepInput` above says why), plus `depends_on`, whose values are step
|
|
131
|
+
* names by definition.
|
|
132
|
+
*
|
|
133
|
+
* @see api/docs/biz/40-cookbooks/variable-references.md
|
|
134
|
+
* @see api/docs/governance/confirmations/cookbook-validation-placement.md
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/** `helperName(...)` — the shape `WorkflowOrchestrator` routes to its registry. */
|
|
138
|
+
const HELPER_CALL = /^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/;
|
|
139
|
+
|
|
140
|
+
/** Root of the step namespace; the only root whose names a recipe can be checked against. */
|
|
141
|
+
const STEPS_ROOT = 'steps';
|
|
142
|
+
|
|
143
|
+
function helperCallProblem(stepId, helperName, expression) {
|
|
144
|
+
return `Step ${stepId} input calls the template helper "${helperName}" - `
|
|
145
|
+
+ `Tier-1 runs no helper registry, so "{{${expression}}}" would reach the handler as literal `
|
|
146
|
+
+ 'text while the production orchestrator evaluates it (and throws when the helper name is '
|
|
147
|
+
+ 'unknown). '
|
|
148
|
+
+ 'Fix: remove the helper call from the step input; a Tier-1 recipe builds its values from '
|
|
149
|
+
+ 'earlier steps (api/docs/biz/40-cookbooks/variable-references.md).';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function undefinedStepProblem(stepId, referencedId, expression, knownIds) {
|
|
153
|
+
return `Step ${stepId} references an undefined step - "{{${expression}}}" names "${referencedId}", `
|
|
154
|
+
+ 'which is not a step_id of this cookbook. '
|
|
155
|
+
+ `Expected one of: ${knownIds.join(', ')}. `
|
|
156
|
+
+ 'Fix: correct the step_id in the reference, or add the missing step '
|
|
157
|
+
+ '(api/docs/biz/40-cookbooks/variable-references.md § Unresolved references).';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function undefinedDependencyProblem(stepId, dependencyId, knownIds) {
|
|
161
|
+
return `Step ${stepId} depends on an undefined step - "depends_on" names "${dependencyId}", `
|
|
162
|
+
+ 'which is not a step_id of this cookbook. '
|
|
163
|
+
+ `Expected one of: ${knownIds.join(', ')}. `
|
|
164
|
+
+ 'Fix: correct the step_id in "depends_on", or add the missing step '
|
|
165
|
+
+ '(api/docs/biz/40-cookbooks/format.md § Step definition).';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What ONE expression is, measured against the names this cookbook defines.
|
|
170
|
+
*
|
|
171
|
+
* A path whose root is not `steps` — `api_input.…`, `context.…`, `current.…` —
|
|
172
|
+
* is left alone: its value comes into being at run time and a recipe says
|
|
173
|
+
* nothing about whether it will. The BRACKET form `steps[0]` is not a step
|
|
174
|
+
* reference either, in this runner or at the gateway; the dotted `steps.0` is,
|
|
175
|
+
* and it names a step no recipe can define.
|
|
176
|
+
*
|
|
177
|
+
* @returns {string|null} the problem, or `null` when the expression is fine
|
|
178
|
+
*/
|
|
179
|
+
function checkExpression(expression, stepId, known, knownIds) {
|
|
180
|
+
const helperCall = expression.match(HELPER_CALL);
|
|
181
|
+
if (helperCall) {
|
|
182
|
+
return helperCallProblem(stepId, helperCall[1], expression);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parts = expression.split('.');
|
|
186
|
+
if (parts[0] !== STEPS_ROOT || parts.length < 2) return null;
|
|
187
|
+
|
|
188
|
+
const referencedId = parts[1];
|
|
189
|
+
if (known.has(referencedId)) return null;
|
|
190
|
+
|
|
191
|
+
return undefinedStepProblem(stepId, referencedId, expression, knownIds);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The problem this cookbook's step inputs carry, or `null` when they carry none.
|
|
196
|
+
*
|
|
197
|
+
* Returns the FIRST problem rather than throwing, the way
|
|
198
|
+
* `utils/cookbookFormat.checkCookbookFormatVersion` does: the caller owns the
|
|
199
|
+
* `[Context]` of the message it throws, and the caller here is the runner
|
|
200
|
+
* (`.claude/rules/architecture-principles.md` §5).
|
|
201
|
+
*
|
|
202
|
+
* @param {Array<Object>} steps - the cookbook's steps, already proved to be the array shape
|
|
203
|
+
* @returns {Promise<string|null>} the problem text without its context prefix
|
|
204
|
+
*/
|
|
205
|
+
async function checkStepInputExpressions(steps) {
|
|
206
|
+
const knownIds = steps
|
|
207
|
+
.map((step) => step && step.step_id)
|
|
208
|
+
.filter((stepId) => typeof stepId === 'string' && stepId.length > 0);
|
|
209
|
+
const known = new Set(knownIds);
|
|
210
|
+
|
|
211
|
+
for (const step of steps) {
|
|
212
|
+
const dependencies = Array.isArray(step.depends_on) ? step.depends_on : [];
|
|
213
|
+
for (const dependency of dependencies) {
|
|
214
|
+
if (typeof dependency !== 'string' || known.has(dependency)) continue;
|
|
215
|
+
return undefinedDependencyProblem(step.step_id, dependency, knownIds);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let problem = null;
|
|
219
|
+
await resolveReferencesWith(step.input, (expression) => {
|
|
220
|
+
if (problem === null) {
|
|
221
|
+
problem = checkExpression(expression, step.step_id, known, knownIds);
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
});
|
|
225
|
+
if (problem !== null) return problem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Write one finished step into the run context, under its own name.
|
|
233
|
+
*
|
|
234
|
+
* `output` appears ONLY on a step that passed — "an entry carries `output` and
|
|
235
|
+
* `_execution` once it has completed, not before"
|
|
236
|
+
* (the same "Runtime context (`context.steps`)" section). A step that failed is
|
|
237
|
+
* recorded with its status and no output, so a reference into it resolves to
|
|
238
|
+
* nothing and survives as literal text: the cookbook author sees
|
|
239
|
+
* `{{steps.x.output.id}}` sitting in the failing step's input, which names the
|
|
240
|
+
* real cause.
|
|
241
|
+
*
|
|
242
|
+
* The `step_id` is REQUIRED here rather than assumed. `validateCookbook`
|
|
243
|
+
* already refuses a cookbook whose step has none, so in a run this cannot
|
|
244
|
+
* happen; the check is at the entry of this function because the alternative is
|
|
245
|
+
* a silent `steps[undefined]` — a nameless step that a later reference could
|
|
246
|
+
* never address on purpose (architecture-principles.md §4).
|
|
247
|
+
*
|
|
248
|
+
* @param {{steps: Object}} runContext - the context to write into
|
|
249
|
+
* @param {Object} step - the cookbook step as written
|
|
250
|
+
* @param {{passed: boolean, actual: *}} result - the step's result record
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function recordStepOutcome(runContext, step, result) {
|
|
254
|
+
if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
|
|
255
|
+
throw new Error('[stepReferences] A step with no step_id cannot enter the run context - Expected a '
|
|
256
|
+
+ 'non-empty "step_id"; a step is addressed by its name and never by its position '
|
|
257
|
+
+ '(api/docs/biz/40-cookbooks/variable-references.md § Rules). '
|
|
258
|
+
+ 'Fix: add "step_id" to the step.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const entry = {
|
|
262
|
+
...step,
|
|
263
|
+
_execution: { status: result.passed ? 'completed' : 'failed' }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (result.passed) {
|
|
267
|
+
entry.output = result.actual;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
runContext.steps[step.step_id] = entry;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createRunContext,
|
|
275
|
+
resolveStepInput,
|
|
276
|
+
recordStepOutcome,
|
|
277
|
+
checkStepInputExpressions
|
|
278
|
+
};
|
|
@@ -906,8 +906,14 @@ class ServiceStructureValidator {
|
|
|
906
906
|
this.errors.push({
|
|
907
907
|
type: 'MISSING_COOKBOOKS',
|
|
908
908
|
path: 'tests/cookbooks',
|
|
909
|
+
// The problem only. The remedy is `fix`, and the printer composes the
|
|
910
|
+
// two — `formatResult` below writes a `Fix:` line of its own, the
|
|
911
|
+
// service wrapper composes `message (Fix: fix)` inline. A message that
|
|
912
|
+
// carries `Fix:` as well prints the remedy twice, which reads as two
|
|
913
|
+
// instructions; this was the only one of the validator's findings that
|
|
914
|
+
// did it (d.599).
|
|
909
915
|
message: '[ServiceStructure] tests/cookbooks is missing or empty - Tier-1 boot '
|
|
910
|
-
+ 'validates operations by these recipes.
|
|
916
|
+
+ 'validates operations by these recipes.',
|
|
911
917
|
fix: 'add tests/cookbooks/<op>.json'
|
|
912
918
|
});
|
|
913
919
|
return;
|
|
@@ -288,6 +288,44 @@ deploy-production:
|
|
|
288
288
|
# (api/docs/setup/INSTALL.md, applying a business schema).
|
|
289
289
|
BIZ_DB_MIGRATIONS="$(dirname "$BIZ_DB_MIGRATIONS")"
|
|
290
290
|
fi
|
|
291
|
+
# WHICH SEEDS, IF ANY. The declaration says which files the installer
|
|
292
|
+
# applies; the file's own `-- Dataset-Class:` header says what class each
|
|
293
|
+
# one is (api/docs/standards/repository-installation-sql-contract.md §4).
|
|
294
|
+
# Neither fact is copied here, and the box is handed the ANSWER: it parses
|
|
295
|
+
# no JSON, reads no header and decides no class.
|
|
296
|
+
#
|
|
297
|
+
# Only PRODUCTION_LIKE reaches a production database. A service may declare
|
|
298
|
+
# a TEST_ONLY seed in the same list - converter does - and applying the
|
|
299
|
+
# declaration verbatim would write synthetic fixtures into a live schema.
|
|
300
|
+
BIZ_DB_SEEDS=""
|
|
301
|
+
for seed in $(jq -r '.database.seeds // [] | .[]' "$CONTRACT"); do
|
|
302
|
+
seed_file="$CI_PROJECT_DIR/$seed"
|
|
303
|
+
if [ ! -f "$seed_file" ]; then
|
|
304
|
+
echo "[deploy] FATAL: $CONTRACT declares database.seeds \"$seed\" and this checkout does not carry it. Expected: the file, at that path. Fix: add it, or correct the declaration (uniform row D-DB-PACKAGE reports the same gap)." >&2
|
|
305
|
+
exit 1
|
|
306
|
+
fi
|
|
307
|
+
seed_class="$(sed -n 's/^-- Dataset-Class:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
|
|
308
|
+
case "$seed_class" in
|
|
309
|
+
PRODUCTION_LIKE)
|
|
310
|
+
# This deploy applies every PRODUCTION_LIKE seed on EVERY run and
|
|
311
|
+
# keeps no tracker (see the step on the box for why). A file that
|
|
312
|
+
# does not converge on a replay would duplicate rows or fail, so the
|
|
313
|
+
# header that licenses the replay is a precondition of the deploy,
|
|
314
|
+
# not a comment in the file.
|
|
315
|
+
seed_idem="$(sed -n 's/^-- Idempotency:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
|
|
316
|
+
if [ "$seed_idem" != "yes" ]; then
|
|
317
|
+
echo "[deploy] FATAL: $seed declares \"-- Idempotency: $seed_idem\" and this deploy applies every PRODUCTION_LIKE seed on every run, with no tracker. Expected: Idempotency: yes. Fix: make the seed converge on a replay (INSERT ... ON DUPLICATE KEY UPDATE), or move the part that does not into a migration." >&2
|
|
318
|
+
exit 1
|
|
319
|
+
fi
|
|
320
|
+
BIZ_DB_SEEDS="$BIZ_DB_SEEDS $seed" ;;
|
|
321
|
+
TEST_ONLY)
|
|
322
|
+
echo "[deploy] seed $seed NOT APPLIED (Dataset-Class: TEST_ONLY) - test fixtures never reach a production database." ;;
|
|
323
|
+
*)
|
|
324
|
+
echo "[deploy] FATAL: $seed declares \"-- Dataset-Class: $seed_class\" and database.seeds admits PRODUCTION_LIKE and TEST_ONLY only. Fix: correct the header (uniform row D-DB-HEADERS owns it), or remove the file from database.seeds." >&2
|
|
325
|
+
exit 1 ;;
|
|
326
|
+
esac
|
|
327
|
+
done
|
|
328
|
+
BIZ_DB_SEEDS="${BIZ_DB_SEEDS# }"
|
|
291
329
|
# DEPLOY_PATH / DEPLOY_HOST / DEPLOY_USER are CI variables: the box decides
|
|
292
330
|
# where its checkout lives, and no path is baked into this file.
|
|
293
331
|
#
|
|
@@ -314,7 +352,7 @@ deploy-production:
|
|
|
314
352
|
printf '%s\n' "set -euo pipefail"
|
|
315
353
|
cat "$API_CHECKOUT/scripts/lib/mariadb-migrations.sh"
|
|
316
354
|
cat
|
|
317
|
-
} <<'REMOTE' | ssh $DEPLOY_USER@$DEPLOY_HOST "IFS= read -r REGISTRY_PASSWORD; export REGISTRY_PASSWORD; bash -s -- '$DEPLOY_PATH' '$CI_REGISTRY' '$CI_REGISTRY_USER' '$CI_PROJECT_PATH_SLUG' '$BIZ_IMAGE_DIGEST' '$CI_PROJECT_PATH' '$BIZ_DB_SCHEMA' '$BIZ_DB_MIGRATIONS'"
|
|
355
|
+
} <<'REMOTE' | ssh $DEPLOY_USER@$DEPLOY_HOST "IFS= read -r REGISTRY_PASSWORD; export REGISTRY_PASSWORD; bash -s -- '$DEPLOY_PATH' '$CI_REGISTRY' '$CI_REGISTRY_USER' '$CI_PROJECT_PATH_SLUG' '$BIZ_IMAGE_DIGEST' '$CI_PROJECT_PATH' '$BIZ_DB_SCHEMA' '$BIZ_DB_MIGRATIONS' '$BIZ_DB_SEEDS' '$CI_COMMIT_SHA'"
|
|
318
356
|
DEPLOY_PATH="$1"
|
|
319
357
|
REGISTRY="$2"
|
|
320
358
|
REGISTRY_USER="$3"
|
|
@@ -325,6 +363,14 @@ deploy-production:
|
|
|
325
363
|
# only thing that turns the migration step below on or off.
|
|
326
364
|
DB_SCHEMA="$7"
|
|
327
365
|
DB_MIGRATIONS_DIR="$8"
|
|
366
|
+
# Space-separated, repo-relative, and already filtered to PRODUCTION_LIKE on
|
|
367
|
+
# the runner - the box parses no JSON and decides no class. Empty when the
|
|
368
|
+
# contract declares no seeds, or declares only ones no production database
|
|
369
|
+
# may see.
|
|
370
|
+
DB_SEEDS="$9"
|
|
371
|
+
# The commit the runner MEASURED the contract at. The box is moved to it
|
|
372
|
+
# below, so both sides read one declaration rather than two.
|
|
373
|
+
COMMIT_SHA="${10}"
|
|
328
374
|
# This service's own env file on the box, the per-machine half of the two
|
|
329
375
|
# the compose file names (../shared.env is the host's). It carries the
|
|
330
376
|
# migration account - the SAME account the service connects as.
|
|
@@ -355,12 +401,39 @@ deploy-production:
|
|
|
355
401
|
# R2: reset, never merge. A local modification on the server would turn a
|
|
356
402
|
# merge into a conflict and abort the deploy halfway.
|
|
357
403
|
git fetch origin production
|
|
358
|
-
|
|
404
|
+
# ...and to the COMMIT THIS PIPELINE MEASURED, never to whatever
|
|
405
|
+
# origin/production points at by the time the ssh step opens. The runner
|
|
406
|
+
# read config/service/integration-contract.json at $CI_COMMIT_SHA and
|
|
407
|
+
# derived the migration directory and the seed list from it THERE; a push
|
|
408
|
+
# that lands while this job runs would otherwise leave the box applying a
|
|
409
|
+
# declaration nobody measured.
|
|
410
|
+
#
|
|
411
|
+
# It must still be ON the branch this box follows. A commit that is not is
|
|
412
|
+
# a pipeline for a different history, and resetting to it would put this
|
|
413
|
+
# box on code production never took.
|
|
414
|
+
if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/production; then
|
|
415
|
+
echo "[deploy] FATAL: $COMMIT_SHA is not an ancestor of origin/production - this pipeline measured a commit that is not on the branch this box deploys, and resetting to it would put $PROJECT_PATH on a history production never took. Expected: the pipeline of a commit that is on production. Fix: re-run the pipeline of the current tip of production." >&2
|
|
416
|
+
exit 1
|
|
417
|
+
fi
|
|
418
|
+
git reset --hard "$COMMIT_SHA"
|
|
359
419
|
mkdir -p "$DOCKER_CONFIG_DIR"
|
|
360
420
|
echo "$REGISTRY_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
|
|
361
421
|
echo "[deploy] pulling $BIZ_IMAGE_DIGEST"
|
|
362
422
|
docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml pull
|
|
363
423
|
|
|
424
|
+
# Both env files, for EVERY service, BEFORE anything branches on a database.
|
|
425
|
+
# docker-compose.production.yml names them, so a missing one is fatal whether
|
|
426
|
+
# or not this service has a schema - and a service without one (pdfgen, hello)
|
|
427
|
+
# used to learn it from compose alone, as `env file ... not found`: a line that
|
|
428
|
+
# names no runbook and no owner. Neither file is committed, and neither is
|
|
429
|
+
# created by a script; the runbook step below is what puts them on the box.
|
|
430
|
+
for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
|
|
431
|
+
if [ ! -f "$env_file" ]; then
|
|
432
|
+
echo "[deploy] Missing env file - $DEPLOY_PATH/$env_file does not exist on this box, and docker-compose.production.yml names it: the service could not start without it. Expected: both env files that compose names, on every box - the host-level $HOST_ENV and this service's own $SERVICE_ENV. Fix: place it on the box (both are per-machine and never committed) - api/docs/operations/production-box-provisioning.md § 3 The biz box, step 3." >&2
|
|
433
|
+
exit 1
|
|
434
|
+
fi
|
|
435
|
+
done
|
|
436
|
+
|
|
364
437
|
# ─── Migrations: after the pull, before the switch ───────────────
|
|
365
438
|
# Owner decision api/docs/governance/confirmations/db-migrations-first-deploy.md
|
|
366
439
|
# 001, phase 2. The ordering IS the safety: at this point the new image is
|
|
@@ -380,16 +453,14 @@ deploy-production:
|
|
|
380
453
|
if [ -z "$DB_SCHEMA" ]; then
|
|
381
454
|
echo "[deploy] migrations NOT APPLICABLE (no database block) - config/service/integration-contract.json declares no database, so this service has no schema to migrate."
|
|
382
455
|
else
|
|
383
|
-
# The two env files the compose names, in the order it names them:
|
|
384
|
-
# host-level one first, this service's own second, so the same value
|
|
385
|
-
# here that wins inside the container. Reading only one of them would
|
|
456
|
+
# The two env files the compose names, READ in the order it names them:
|
|
457
|
+
# the host-level one first, this service's own second, so the same value
|
|
458
|
+
# wins here that wins inside the container. Reading only one of them would
|
|
386
459
|
# make this step disagree with the service it migrates for, depending on
|
|
387
|
-
# which file the box happens to carry DB_HOST in.
|
|
460
|
+
# which file the box happens to carry DB_HOST in. That both exist was
|
|
461
|
+
# settled above, for every service - checking it twice would be two rails
|
|
462
|
+
# over one fact (.claude/rules/change-discipline.md § One rail per concern).
|
|
388
463
|
for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
|
|
389
|
-
if [ ! -f "$env_file" ]; then
|
|
390
|
-
echo "[deploy] Missing env file - $env_file does not exist on this box, and docker-compose.production.yml names it: between the two of them they carry the endpoint and the migration account of $DB_SCHEMA. Fix: place it on the box (both are per-machine and never committed), then re-run this deploy." >&2
|
|
391
|
-
exit 1
|
|
392
|
-
fi
|
|
393
464
|
set -a
|
|
394
465
|
. "$env_file"
|
|
395
466
|
set +a
|
|
@@ -403,6 +474,15 @@ deploy-production:
|
|
|
403
474
|
echo "[deploy] Missing environment variable -$missing in $DEPLOY_PATH/$SERVICE_ENV. Expected: the migration account of $DB_SCHEMA (the same account as DB_USER, granted on that schema only) and the endpoint it is reached at. Fix: add the keys to that file on this box; config/env-templates/ declares each of them with the reason it exists." >&2
|
|
404
475
|
exit 1
|
|
405
476
|
fi
|
|
477
|
+
# The runner is carried from the api clone at API_UNIFORM_REF, so a ref
|
|
478
|
+
# older than the seed entry point would reach `command not found` here -
|
|
479
|
+
# after the migrations and before the switch, in a production deploy and
|
|
480
|
+
# nowhere else. Checked before the first statement instead
|
|
481
|
+
# (automation-gates.md §1 requirement 4).
|
|
482
|
+
if [ -n "$DB_SEEDS" ] && ! declare -F apply_mariadb_seeds_over_tcp > /dev/null 2>&1; then
|
|
483
|
+
echo "[deploy] FATAL: the migration runner carried from the api clone has no apply_mariadb_seeds_over_tcp, and $DB_SCHEMA declares PRODUCTION_LIKE seeds this deploy has to apply. Expected: that entry point in api/scripts/lib/mariadb-migrations.sh. Fix: move API_UNIFORM_REF to an api commit that carries it, and re-run this deploy." >&2
|
|
484
|
+
exit 1
|
|
485
|
+
fi
|
|
406
486
|
# The precondition confirmation 001 demands before any migration: the
|
|
407
487
|
# account opens the schema, or this deploy stops and names the runbook.
|
|
408
488
|
if ! mariadb_migrations_can_open_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"; then
|
|
@@ -435,6 +515,24 @@ deploy-production:
|
|
|
435
515
|
# anywhere in a deploy job, and the gate that keeps this job free of one
|
|
436
516
|
# judges by the word, not by the intent.
|
|
437
517
|
echo "[deploy] migrations $DB_SCHEMA: $MARIADB_MIGRATIONS_APPLIED applied now, $(wc -l < "$TRACKER" | tr -d " ") recorded in $DEPLOY_PATH/$TRACKER"
|
|
518
|
+
# Seeds: after the migrations, before the switch, for the same reason the
|
|
519
|
+
# migrations are there - the schema they write into is the one the step
|
|
520
|
+
# above just brought up to date, and the old container is still serving.
|
|
521
|
+
#
|
|
522
|
+
# They are NOT tracked. The tracker answers "has this file ever been
|
|
523
|
+
# applied", and a PRODUCTION_LIKE seed is a regenerated reference set
|
|
524
|
+
# whose name never changes and whose content does - converter's system
|
|
525
|
+
# catalogue is written by a generator in its own repository. Tracking it
|
|
526
|
+
# would pin production to the first catalogue ever installed, which is the
|
|
527
|
+
# drift this step exists to close. What licenses the replay is the file's own
|
|
528
|
+
# `-- Idempotency: yes`, and the runner side refused this deploy if it did
|
|
529
|
+
# not say so.
|
|
530
|
+
if [ -z "$DB_SEEDS" ]; then
|
|
531
|
+
echo "[deploy] seeds NOT APPLICABLE - config/service/integration-contract.json declares no PRODUCTION_LIKE seed for $DB_SCHEMA."
|
|
532
|
+
else
|
|
533
|
+
apply_mariadb_seeds_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA" $DB_SEEDS
|
|
534
|
+
echo "[deploy] seeds $DB_SCHEMA: $MARIADB_SEEDS_APPLIED applied (every deploy, no tracker)"
|
|
535
|
+
fi
|
|
438
536
|
fi
|
|
439
537
|
|
|
440
538
|
docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml up -d
|
|
@@ -1,27 +1,60 @@
|
|
|
1
1
|
# === Production stage (build with: --target production) ===
|
|
2
2
|
FROM node:24-alpine AS production
|
|
3
3
|
WORKDIR /app
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
4
|
+
# The identity the PROCESS runs as, and it is the same one in every environment:
|
|
5
|
+
# "production is dev in the rights of the process". Until d.588 this stage
|
|
6
|
+
# declared no USER at all, so a deployed service ran as root while its dev
|
|
7
|
+
# container ran as 1000:1000 — measured 2026-09-17 on an image built from this
|
|
8
|
+
# template, `docker run --rm <image> id` → uid=0(root) gid=0(root). Nobody had
|
|
9
|
+
# decided that difference; it was what the file happened to say.
|
|
10
|
+
#
|
|
11
|
+
# `node` is the account node:24-alpine ships at uid 1000, gid 1000 (measured the
|
|
12
|
+
# same day: `docker run --rm node:24-alpine id node` → uid=1000(node)
|
|
13
|
+
# gid=1000(node)), so USER node here and `user: "1000:1000"` in both compose
|
|
14
|
+
# files are two spellings of ONE identity, not two decisions.
|
|
10
15
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# The line below runs while /app is still root's, because WORKDIR created it so,
|
|
17
|
+
# and it does two things at once for that reason:
|
|
18
|
+
# * logs/ and conn-runtime/ are written at RUNTIME — @onlineapps/monitoring-core
|
|
19
|
+
# writes logs/app.<date>.log (its fileDirectory default) and ServiceWrapper
|
|
20
|
+
# writes conn-runtime/validation-proof.json on every boot. Both are excluded
|
|
21
|
+
# from the build context by .dockerignore, and in production /app is the
|
|
22
|
+
# image rather than a bind mount, so they exist only because this line
|
|
23
|
+
# creates them;
|
|
24
|
+
# * the handover of /app itself, after which every COPY carries --chown and
|
|
25
|
+
# nothing in the image belongs to uid 0.
|
|
26
|
+
RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app
|
|
27
|
+
USER node
|
|
28
|
+
COPY --chown=node:node package*.json ./
|
|
29
|
+
RUN npm ci --omit=dev
|
|
30
|
+
COPY --chown=node:node . .
|
|
31
|
+
# No conformance check here. From d.215b the stage ran `oa-validate`, on the
|
|
32
|
+
# sentence of confirmation biz-service-manifest 001 §3.1 ("an image with a
|
|
33
|
+
# finding is never built"); three later decisions took that away. 006 point 2
|
|
34
|
+
# calls the run inside an image "information, never a gate" — which a non-zero
|
|
35
|
+
# exit is not; 010 made the job `validate-uniform` run the COMPLETE uniform over
|
|
36
|
+
# a real checkout in every pipeline, and again before the deploy; 011 settled
|
|
37
|
+
# that a tree which is not a git checkout reports NOT RUN, never a verdict.
|
|
15
38
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
39
|
+
# What was left was a gate over a tree where the uniform cannot be answered: an
|
|
40
|
+
# image has no workspace above it, no `.git`, and — once `.dockerignore` does its
|
|
41
|
+
# job — no `README.md` and no `docker-compose*.yml` either. Measured 2026-09-17
|
|
42
|
+
# over exactly that shape: NOT DEPLOYABLE, 10 findings, every one of them
|
|
43
|
+
# "absent", exit 1. The deployability verdict is CI's; this stage builds.
|
|
21
44
|
CMD ["node", "index.js"]
|
|
22
45
|
|
|
23
46
|
# === Development stage (default) ===
|
|
24
47
|
FROM node:24-alpine
|
|
25
48
|
WORKDIR /app
|
|
26
|
-
|
|
49
|
+
# The same identity as the production stage — and here the directory is load
|
|
50
|
+
# bearing for a second reason. docker-compose.yml gives the one-shot test runner
|
|
51
|
+
# an anonymous volume at /app/conn-runtime so its validation proof never lands in
|
|
52
|
+
# the file the running service reads, and docker seeds such a volume from the
|
|
53
|
+
# IMAGE at that path. Measured 2026-09-17 with the path absent: docker created it
|
|
54
|
+
# root:root and the runner's write failed with "Permission denied"; with the
|
|
55
|
+
# directory present and owned by node, the volume inherits that ownership and the
|
|
56
|
+
# write succeeds.
|
|
57
|
+
RUN mkdir -p /app/logs /app/conn-runtime && chown -R node:node /app
|
|
58
|
+
USER node
|
|
59
|
+
COPY --chown=node:node package*.json ./
|
|
27
60
|
CMD ["npm", "start"]
|
|
@@ -9,15 +9,15 @@ Duty sections that apply:
|
|
|
9
9
|
- `scripts`: S-TEST, S-ALL-C, S-INT-C, S-UNIT-C, S-COOKBOOKS, S-COOK-C, S-HOST, S-HOOKS
|
|
10
10
|
- `tooling`: S-SCRIPTS
|
|
11
11
|
- `config`: C-IDENTITY, C-SERVICE, C-OPS, C-CONTRACT, C-CONNECTORS, C-SERVICE-DEAD, C-ENV, G-SHARED-ENV, C-ENV-READS
|
|
12
|
-
- `runtime`: R-NODE, R-MEM, R-PID1, R-PORTS-DEV, R-PORTS
|
|
13
|
-
- `db`: D-DB-CONSISTENT, D-DB-COLLATION, D-DB-PACKAGE, D-DB-NAMING, D-DB-README, D-DB-ACCOUNT, D-DB-HEADERS
|
|
12
|
+
- `runtime`: R-NODE, R-MEM, R-PID1, R-USER, R-PORTS-DEV, R-PORTS
|
|
13
|
+
- `db`: D-DB-CONSISTENT, D-DB-COLLATION, D-DB-PACKAGE, D-DB-NAMING, D-DB-README, D-DB-ACCOUNT, D-DB-CI-ACCOUNT, D-DB-HEADERS
|
|
14
14
|
- `docs`: C-LINT, D-PORT, D-NPM, D-SCRIPT, D-RETIRED, D-HEADER, D-LINT
|
|
15
15
|
|
|
16
16
|
Paths a duty owns:
|
|
17
17
|
|
|
18
18
|
- `.dockerignore`: F-DOCKERIGNORE (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/gitignore`)
|
|
19
19
|
- `.gitignore`: F-GITIGNORE (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/gitignore`)
|
|
20
|
-
- `.gitlab-ci.yml`: G-CI (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/.gitlab-ci.yml`)
|
|
20
|
+
- `.gitlab-ci.yml`: G-CI (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/.gitlab-ci.yml`), D-DB-CI-ACCOUNT
|
|
21
21
|
- `README.md`: G-README (from `./node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json`)
|
|
22
22
|
- `config/biz-docs-lint.tree.json`: C-LINT
|
|
23
23
|
- `config/env-templates`: C-ENV, D-DB-ACCOUNT
|
|
@@ -26,7 +26,7 @@ Paths a duty owns:
|
|
|
26
26
|
- `config/service/integration-contract.json`: C-CONTRACT, D-DB-COLLATION
|
|
27
27
|
- `config/service/operations.json`: C-OPS
|
|
28
28
|
- `docker-compose.production.yml`: G-PROD (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.production.yml`), G-PROD-IMAGE, R-PORTS
|
|
29
|
-
- `docker-compose.yml`: F-RUNNER (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.yml`), C-IDENTITY, R-MEM, R-PID1, R-PORTS-DEV
|
|
29
|
+
- `docker-compose.yml`: F-RUNNER (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docker-compose.yml`), C-IDENTITY, R-MEM, R-PID1, R-USER, R-PORTS-DEV
|
|
30
30
|
- `docs/80-setup/`: G-SETUP
|
|
31
31
|
- `docs/80-setup/INSTALL.md`: G-SETUP-INSTALL (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docs/80-setup/INSTALL.md`)
|
|
32
32
|
- `docs/80-setup/PLATFORM_MATRIX.md`: G-SETUP-MATRIX (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md`)
|
|
@@ -220,3 +220,41 @@ cgroup.
|
|
|
220
220
|
Revise the service limit when this service exceeds 60% of it in operation,
|
|
221
221
|
measured the way the norm was: cgroup `memory.stat:anon`, sampled inside the
|
|
222
222
|
running container.
|
|
223
|
+
|
|
224
|
+
## Process identity
|
|
225
|
+
|
|
226
|
+
One identity, in every environment: the service process is **uid 1000, gid
|
|
227
|
+
1000** — the `node` account of `node:24-alpine` — and it is never root.
|
|
228
|
+
|
|
229
|
+
| Where | What says it |
|
|
230
|
+
|---|---|
|
|
231
|
+
| the image, both stages | `USER node` in `Dockerfile` |
|
|
232
|
+
| dev container and test runner | `user: "1000:1000"` in `docker-compose.yml` |
|
|
233
|
+
| production container | `user: "1000:1000"` in `docker-compose.production.yml` |
|
|
234
|
+
|
|
235
|
+
Row `R-USER` of the uniform holds the two halves nothing else reaches — the
|
|
236
|
+
production stage of the `Dockerfile` and the service node of `docker-compose.yml`.
|
|
237
|
+
The runner line is inside the block `F-RUNNER` holds, and the production compose is
|
|
238
|
+
`G-PROD`'s whole-file render, so neither is measured twice.
|
|
239
|
+
|
|
240
|
+
Both halves are stated on purpose. The `Dockerfile` decides what the IMAGE is,
|
|
241
|
+
the compose file decides what THIS deployment runs, and a rebuild that lost the
|
|
242
|
+
`USER` would otherwise reach the box unnoticed. Until 2026-09-17 neither half
|
|
243
|
+
existed for production: a `docker run --rm <image> id` on an image built from
|
|
244
|
+
this template answered `uid=0(root)`, while the dev container beside it ran as
|
|
245
|
+
1000 — a difference nobody had decided.
|
|
246
|
+
|
|
247
|
+
The two directories the process writes at runtime, `logs/` (the log files
|
|
248
|
+
`@onlineapps/monitoring-core` rotates) and `conn-runtime/` (the validation
|
|
249
|
+
proof), are created in the image and belong to that account. In dev they are
|
|
250
|
+
covered by the bind mount and belong to whoever owns the checkout; in production
|
|
251
|
+
`/app` IS the image, so a directory nothing created is a directory nothing can
|
|
252
|
+
write.
|
|
253
|
+
|
|
254
|
+
The one-shot test runner gets a `conn-runtime/` of its own — an anonymous volume
|
|
255
|
+
at `/app/conn-runtime` in its compose node. A validation proof is an assertion
|
|
256
|
+
about ONE running instance, and with the plain `.:/app` mount the runner wrote
|
|
257
|
+
its proof into the file the live service reads (measured in `api_biz/converter`,
|
|
258
|
+
2026-09-16). Nothing else is separated: the runner keeps the same build, the
|
|
259
|
+
same `env_file` and the same networks as the service, because that is what makes
|
|
260
|
+
a suite measure what the service sees.
|
|
@@ -13,7 +13,7 @@ LOG_MAX_SIZE_BYTES=52428800
|
|
|
13
13
|
# How many rotated files of one log row the directory keeps - the other half of the size bound, and equally without a default in @onlineapps/monitoring-core 3.0.0 (src/logger.js REQUIRED_FILE_BOUNDS, src/config.js logMaxFiles). Ten files of LOG_MAX_SIZE_BYTES is the 500 MB per service the platform declares as its ceiling, so the two keys are read together and travel together (owner decision docs/governance/confirmations/log-file-bounds.md 001).
|
|
14
14
|
LOG_MAX_FILES=10
|
|
15
15
|
|
|
16
|
-
# The HMAC-SHA256 secret the gateway and auth sign and verify access tokens with; it is shared because both ends of one token must hold the same value (docs/standards/JWT_AUTH.md).
|
|
16
|
+
# The HMAC-SHA256 secret the gateway and auth sign and verify access tokens with; it is shared because both ends of one token must hold the same value (docs/standards/JWT_AUTH.md). Four infrastructure services read the name, each at boot: api_gateway (infra/api_gateway/config/jwtSecret.js, assertJwtSecret), api_auth (infra/api_auth/config/bootEnv.js, assertJwtSecret), api_meta_reader and api_delivery_endpoint (both requireEnv('JWT_SECRET', ...) in src/config.js). In the business chain the key is carried, not read: no business service reads the name, and @onlineapps/service-common 3.0.0 takes the secret as an INPUT of verifyAccessToken(token, secret) and createJwtValidator({ secret }) and reads no environment variable at all (principle 1, CHANGELOG 3.0.0), so a business service could not use the value even if it were filled in. It reaches every bearer because the shared key set is ONE file whose every copy is byte-identical to the platform template (confirmation biz-service-manifest 003 §18), never because each bearer reads every key - which is why CHANGE_ME here is a real placeholder only on an infrastructure target.
|
|
17
17
|
JWT_SECRET=CHANGE_ME
|
|
18
18
|
|
|
19
19
|
# The ordered list of SecretBox master keys every process that resolves a secret reference decrypts with - entries of <key_id>:<base64 32-byte key> separated by commas, newest FIRST: the first entry seals every new write, every entry opens what it sealed (selected by the blob's own key_id), and a blob naming a key outside the list is refused rather than guessed at (owner decision docs/governance/confirmations/secretbox-master-keys.md 001). It is shared because one keyring has to reach every bearer at once - a rotation is a switch of this one list, and a service left on yesterday's list stops reading what its neighbour has already re-encrypted; the measured cost of the single key it replaces is six of ten rows of oagen_meta.secret unreadable for two weeks after the August 2026 rotation. The value is per-machine and lives only in config/env-active/shared.env. @onlineapps/conn-infra-secrets owns the name and the format and never reads the environment itself (principle 1): the bearer reads this key and hands the string to the connector.
|
|
@@ -33,6 +33,15 @@ services:
|
|
|
33
33
|
# carries a digest, and an image built here would in any case not be the one
|
|
34
34
|
# CI proved. Build locally through docker-compose.yml instead.
|
|
35
35
|
container_name: __CONTAINER_NAME__
|
|
36
|
+
# The same uid:gid the dev compose pins, and the Dockerfile's production
|
|
37
|
+
# stage declares the same identity as USER node (node:24-alpine ships that
|
|
38
|
+
# account at uid 1000, gid 1000). Until d.588 neither file said anything
|
|
39
|
+
# here, so the deployed process was the one on the platform running as root
|
|
40
|
+
# — measured 2026-09-17, `docker run --rm <image> id` → uid=0(root).
|
|
41
|
+
# Both halves are stated on purpose: the Dockerfile decides what the image
|
|
42
|
+
# is, this line decides what THIS deployment runs, and a rebuild that lost
|
|
43
|
+
# the USER would otherwise reach the box unnoticed.
|
|
44
|
+
user: "1000:1000"
|
|
36
45
|
deploy:
|
|
37
46
|
resources:
|
|
38
47
|
limits:
|