@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
|
@@ -68,12 +68,24 @@ variables:
|
|
|
68
68
|
# ABOVE api_biz, so the sibling api/ still lands beside this checkout, which
|
|
69
69
|
# is the layout verify-deploy-uniform.sh checks.
|
|
70
70
|
GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/$CI_CONCURRENT_ID/api_biz/__SERVICE_NAME__
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# biz-service-manifest 008 point 2).
|
|
71
|
+
# WHERE the platform SSOT is read from. Read access is granted by the api
|
|
72
|
+
# project's CI job-token allowlist - the owner's step, outside any
|
|
73
|
+
# repository (confirmation biz-service-manifest 008 point 2).
|
|
75
74
|
API_PROJECT_PATH: onlineapps/oadrive/infra-mono
|
|
76
|
-
|
|
75
|
+
# WHICH STATE of that SSOT a run is measured against - two facts, two names
|
|
76
|
+
# (confirmation biz-service-manifest 012; 010 for the continuous run, 008
|
|
77
|
+
# point 3 for the binding one).
|
|
78
|
+
#
|
|
79
|
+
# The continuous gate judges the commit in front of it, so it measures
|
|
80
|
+
# against the branch the platform develops on - the same one the libraries
|
|
81
|
+
# SSOT is read from. api production moves only at the END of a production
|
|
82
|
+
# path, and pushing it deploys infra, so a continuous gate pointed there
|
|
83
|
+
# measures a months-old ref and goes red for a reason outside this
|
|
84
|
+
# repository (automation-gates.md §5).
|
|
85
|
+
API_UNIFORM_REF_CONTINUOUS: main
|
|
86
|
+
# The deploy confirms against what is being deployed, and the migration
|
|
87
|
+
# runner this job carries onto the box comes from that same clone.
|
|
88
|
+
API_UNIFORM_REF_DEPLOY: production
|
|
77
89
|
|
|
78
90
|
# The uniform of THIS commit, on every pipeline - confirmation
|
|
79
91
|
# biz-service-manifest 010. Entry 008 places the BINDING run before the SSH step
|
|
@@ -102,7 +114,7 @@ validate-uniform:
|
|
|
102
114
|
# second place to keep in step (change-discipline.md - One rail per
|
|
103
115
|
# concern). It comes from the package this repository pins, beside the
|
|
104
116
|
# engine it runs.
|
|
105
|
-
- sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$
|
|
117
|
+
- sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF_CONTINUOUS"
|
|
106
118
|
rules:
|
|
107
119
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
108
120
|
- if: $CI_COMMIT_BRANCH == "main"
|
|
@@ -247,7 +259,7 @@ deploy-production:
|
|
|
247
259
|
# not look everywhere, and nothing reaches the box. There is no flag and no
|
|
248
260
|
# variable that turns this off (automation-gates.md §1 requirement 5).
|
|
249
261
|
- npm ci
|
|
250
|
-
- sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$
|
|
262
|
+
- sh node_modules/@onlineapps/conn-orch-validator/templates/business-service/scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF_DEPLOY"
|
|
251
263
|
# R1: refuse to deploy without the immutable target the build stage published.
|
|
252
264
|
- |
|
|
253
265
|
if [ -z "${BIZ_IMAGE_DIGEST:-}" ]; then
|
|
@@ -288,6 +300,44 @@ deploy-production:
|
|
|
288
300
|
# (api/docs/setup/INSTALL.md, applying a business schema).
|
|
289
301
|
BIZ_DB_MIGRATIONS="$(dirname "$BIZ_DB_MIGRATIONS")"
|
|
290
302
|
fi
|
|
303
|
+
# WHICH SEEDS, IF ANY. The declaration says which files the installer
|
|
304
|
+
# applies; the file's own `-- Dataset-Class:` header says what class each
|
|
305
|
+
# one is (api/docs/standards/repository-installation-sql-contract.md §4).
|
|
306
|
+
# Neither fact is copied here, and the box is handed the ANSWER: it parses
|
|
307
|
+
# no JSON, reads no header and decides no class.
|
|
308
|
+
#
|
|
309
|
+
# Only PRODUCTION_LIKE reaches a production database. A service may declare
|
|
310
|
+
# a TEST_ONLY seed in the same list - converter does - and applying the
|
|
311
|
+
# declaration verbatim would write synthetic fixtures into a live schema.
|
|
312
|
+
BIZ_DB_SEEDS=""
|
|
313
|
+
for seed in $(jq -r '.database.seeds // [] | .[]' "$CONTRACT"); do
|
|
314
|
+
seed_file="$CI_PROJECT_DIR/$seed"
|
|
315
|
+
if [ ! -f "$seed_file" ]; then
|
|
316
|
+
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
|
|
317
|
+
exit 1
|
|
318
|
+
fi
|
|
319
|
+
seed_class="$(sed -n 's/^-- Dataset-Class:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
|
|
320
|
+
case "$seed_class" in
|
|
321
|
+
PRODUCTION_LIKE)
|
|
322
|
+
# This deploy applies every PRODUCTION_LIKE seed on EVERY run and
|
|
323
|
+
# keeps no tracker (see the step on the box for why). A file that
|
|
324
|
+
# does not converge on a replay would duplicate rows or fail, so the
|
|
325
|
+
# header that licenses the replay is a precondition of the deploy,
|
|
326
|
+
# not a comment in the file.
|
|
327
|
+
seed_idem="$(sed -n 's/^-- Idempotency:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$seed_file" | head -n 1)"
|
|
328
|
+
if [ "$seed_idem" != "yes" ]; then
|
|
329
|
+
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
|
|
330
|
+
exit 1
|
|
331
|
+
fi
|
|
332
|
+
BIZ_DB_SEEDS="$BIZ_DB_SEEDS $seed" ;;
|
|
333
|
+
TEST_ONLY)
|
|
334
|
+
echo "[deploy] seed $seed NOT APPLIED (Dataset-Class: TEST_ONLY) - test fixtures never reach a production database." ;;
|
|
335
|
+
*)
|
|
336
|
+
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
|
|
337
|
+
exit 1 ;;
|
|
338
|
+
esac
|
|
339
|
+
done
|
|
340
|
+
BIZ_DB_SEEDS="${BIZ_DB_SEEDS# }"
|
|
291
341
|
# DEPLOY_PATH / DEPLOY_HOST / DEPLOY_USER are CI variables: the box decides
|
|
292
342
|
# where its checkout lives, and no path is baked into this file.
|
|
293
343
|
#
|
|
@@ -314,7 +364,7 @@ deploy-production:
|
|
|
314
364
|
printf '%s\n' "set -euo pipefail"
|
|
315
365
|
cat "$API_CHECKOUT/scripts/lib/mariadb-migrations.sh"
|
|
316
366
|
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'"
|
|
367
|
+
} <<'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
368
|
DEPLOY_PATH="$1"
|
|
319
369
|
REGISTRY="$2"
|
|
320
370
|
REGISTRY_USER="$3"
|
|
@@ -325,6 +375,14 @@ deploy-production:
|
|
|
325
375
|
# only thing that turns the migration step below on or off.
|
|
326
376
|
DB_SCHEMA="$7"
|
|
327
377
|
DB_MIGRATIONS_DIR="$8"
|
|
378
|
+
# Space-separated, repo-relative, and already filtered to PRODUCTION_LIKE on
|
|
379
|
+
# the runner - the box parses no JSON and decides no class. Empty when the
|
|
380
|
+
# contract declares no seeds, or declares only ones no production database
|
|
381
|
+
# may see.
|
|
382
|
+
DB_SEEDS="$9"
|
|
383
|
+
# The commit the runner MEASURED the contract at. The box is moved to it
|
|
384
|
+
# below, so both sides read one declaration rather than two.
|
|
385
|
+
COMMIT_SHA="${10}"
|
|
328
386
|
# This service's own env file on the box, the per-machine half of the two
|
|
329
387
|
# the compose file names (../shared.env is the host's). It carries the
|
|
330
388
|
# migration account - the SAME account the service connects as.
|
|
@@ -355,12 +413,39 @@ deploy-production:
|
|
|
355
413
|
# R2: reset, never merge. A local modification on the server would turn a
|
|
356
414
|
# merge into a conflict and abort the deploy halfway.
|
|
357
415
|
git fetch origin production
|
|
358
|
-
|
|
416
|
+
# ...and to the COMMIT THIS PIPELINE MEASURED, never to whatever
|
|
417
|
+
# origin/production points at by the time the ssh step opens. The runner
|
|
418
|
+
# read config/service/integration-contract.json at $CI_COMMIT_SHA and
|
|
419
|
+
# derived the migration directory and the seed list from it THERE; a push
|
|
420
|
+
# that lands while this job runs would otherwise leave the box applying a
|
|
421
|
+
# declaration nobody measured.
|
|
422
|
+
#
|
|
423
|
+
# It must still be ON the branch this box follows. A commit that is not is
|
|
424
|
+
# a pipeline for a different history, and resetting to it would put this
|
|
425
|
+
# box on code production never took.
|
|
426
|
+
if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/production; then
|
|
427
|
+
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
|
|
428
|
+
exit 1
|
|
429
|
+
fi
|
|
430
|
+
git reset --hard "$COMMIT_SHA"
|
|
359
431
|
mkdir -p "$DOCKER_CONFIG_DIR"
|
|
360
432
|
echo "$REGISTRY_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
|
|
361
433
|
echo "[deploy] pulling $BIZ_IMAGE_DIGEST"
|
|
362
434
|
docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml pull
|
|
363
435
|
|
|
436
|
+
# Both env files, for EVERY service, BEFORE anything branches on a database.
|
|
437
|
+
# docker-compose.production.yml names them, so a missing one is fatal whether
|
|
438
|
+
# or not this service has a schema - and a service without one (pdfgen, hello)
|
|
439
|
+
# used to learn it from compose alone, as `env file ... not found`: a line that
|
|
440
|
+
# names no runbook and no owner. Neither file is committed, and neither is
|
|
441
|
+
# created by a script; the runbook step below is what puts them on the box.
|
|
442
|
+
for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
|
|
443
|
+
if [ ! -f "$env_file" ]; then
|
|
444
|
+
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
|
|
445
|
+
exit 1
|
|
446
|
+
fi
|
|
447
|
+
done
|
|
448
|
+
|
|
364
449
|
# ─── Migrations: after the pull, before the switch ───────────────
|
|
365
450
|
# Owner decision api/docs/governance/confirmations/db-migrations-first-deploy.md
|
|
366
451
|
# 001, phase 2. The ordering IS the safety: at this point the new image is
|
|
@@ -380,16 +465,14 @@ deploy-production:
|
|
|
380
465
|
if [ -z "$DB_SCHEMA" ]; then
|
|
381
466
|
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
467
|
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
|
|
468
|
+
# The two env files the compose names, READ in the order it names them:
|
|
469
|
+
# the host-level one first, this service's own second, so the same value
|
|
470
|
+
# wins here that wins inside the container. Reading only one of them would
|
|
386
471
|
# make this step disagree with the service it migrates for, depending on
|
|
387
|
-
# which file the box happens to carry DB_HOST in.
|
|
472
|
+
# which file the box happens to carry DB_HOST in. That both exist was
|
|
473
|
+
# settled above, for every service - checking it twice would be two rails
|
|
474
|
+
# over one fact (.claude/rules/change-discipline.md § One rail per concern).
|
|
388
475
|
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
476
|
set -a
|
|
394
477
|
. "$env_file"
|
|
395
478
|
set +a
|
|
@@ -403,6 +486,15 @@ deploy-production:
|
|
|
403
486
|
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
487
|
exit 1
|
|
405
488
|
fi
|
|
489
|
+
# The runner is carried from the api clone at API_UNIFORM_REF_DEPLOY, so a ref
|
|
490
|
+
# older than the seed entry point would reach `command not found` here -
|
|
491
|
+
# after the migrations and before the switch, in a production deploy and
|
|
492
|
+
# nowhere else. Checked before the first statement instead
|
|
493
|
+
# (automation-gates.md §1 requirement 4).
|
|
494
|
+
if [ -n "$DB_SEEDS" ] && ! declare -F apply_mariadb_seeds_over_tcp > /dev/null 2>&1; then
|
|
495
|
+
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_DEPLOY to an api commit that carries it, and re-run this deploy." >&2
|
|
496
|
+
exit 1
|
|
497
|
+
fi
|
|
406
498
|
# The precondition confirmation 001 demands before any migration: the
|
|
407
499
|
# account opens the schema, or this deploy stops and names the runbook.
|
|
408
500
|
if ! mariadb_migrations_can_open_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"; then
|
|
@@ -435,6 +527,24 @@ deploy-production:
|
|
|
435
527
|
# anywhere in a deploy job, and the gate that keeps this job free of one
|
|
436
528
|
# judges by the word, not by the intent.
|
|
437
529
|
echo "[deploy] migrations $DB_SCHEMA: $MARIADB_MIGRATIONS_APPLIED applied now, $(wc -l < "$TRACKER" | tr -d " ") recorded in $DEPLOY_PATH/$TRACKER"
|
|
530
|
+
# Seeds: after the migrations, before the switch, for the same reason the
|
|
531
|
+
# migrations are there - the schema they write into is the one the step
|
|
532
|
+
# above just brought up to date, and the old container is still serving.
|
|
533
|
+
#
|
|
534
|
+
# They are NOT tracked. The tracker answers "has this file ever been
|
|
535
|
+
# applied", and a PRODUCTION_LIKE seed is a regenerated reference set
|
|
536
|
+
# whose name never changes and whose content does - converter's system
|
|
537
|
+
# catalogue is written by a generator in its own repository. Tracking it
|
|
538
|
+
# would pin production to the first catalogue ever installed, which is the
|
|
539
|
+
# drift this step exists to close. What licenses the replay is the file's own
|
|
540
|
+
# `-- Idempotency: yes`, and the runner side refused this deploy if it did
|
|
541
|
+
# not say so.
|
|
542
|
+
if [ -z "$DB_SEEDS" ]; then
|
|
543
|
+
echo "[deploy] seeds NOT APPLICABLE - config/service/integration-contract.json declares no PRODUCTION_LIKE seed for $DB_SCHEMA."
|
|
544
|
+
else
|
|
545
|
+
apply_mariadb_seeds_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA" $DB_SEEDS
|
|
546
|
+
echo "[deploy] seeds $DB_SCHEMA: $MARIADB_SEEDS_APPLIED applied (every deploy, no tracker)"
|
|
547
|
+
fi
|
|
438
548
|
fi
|
|
439
549
|
|
|
440
550
|
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:
|
|
@@ -80,6 +80,23 @@ services:
|
|
|
80
80
|
mem_limit: 1g
|
|
81
81
|
volumes:
|
|
82
82
|
- .:/app
|
|
83
|
+
# The runner's OWN conn-runtime, and the reason it is a volume rather than
|
|
84
|
+
# a second bind mount. `conn-runtime/validation-proof.json` is an assertion
|
|
85
|
+
# about ONE running instance
|
|
86
|
+
# (docs/biz/50-lifecycle/validation-responsibilities.md § Data Flow), and
|
|
87
|
+
# with `.:/app` alone the runner writes it into the directory the SERVICE
|
|
88
|
+
# reads — measured in api_biz/converter on 2026-09-16, where a proof
|
|
89
|
+
# produced by a probe schema stood in the place of the service's own.
|
|
90
|
+
#
|
|
91
|
+
# ANONYMOUS on purpose: F-RUNNER holds this block and
|
|
92
|
+
# `oa-sync-template docker-compose.yml` writes this block and nothing else,
|
|
93
|
+
# so a named volume would reach every service without the top-level
|
|
94
|
+
# `volumes:` key that declares it and `docker compose config` would refuse
|
|
95
|
+
# the document. Docker seeds the volume from the IMAGE at that path, which
|
|
96
|
+
# is why the Dockerfile's development stage creates /app/conn-runtime and
|
|
97
|
+
# gives it to uid 1000 — without that, docker creates it root-owned and the
|
|
98
|
+
# runner cannot write it at all.
|
|
99
|
+
- /app/conn-runtime
|
|
83
100
|
env_file:
|
|
84
101
|
- ./config/env-active/shared.env
|
|
85
102
|
- ./config/env-active/__SERVICE_NAME__.env
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
# WHY THREE ARGUMENTS AND NOTHING FROM THE ENVIRONMENT. The script knows what to
|
|
33
33
|
# measure, never where the pieces come from (`architecture-principles.md` §1):
|
|
34
34
|
# the job passes the checkout GitLab made, the api repository URL carrying its
|
|
35
|
-
# job token, and the api ref
|
|
35
|
+
# job token, and the api ref THAT job measures against (the continuous job and
|
|
36
|
+
# the deploy job name different ones - confirmation biz-service-manifest 012). Nothing here reads
|
|
36
37
|
# `process.env`, so the same invocation is reproducible outside CI.
|
|
37
38
|
#
|
|
38
39
|
# THE LAYOUT IT NEEDS, AND WHY THE JOB HAS TO ASK FOR IT. The engine answers the
|
|
@@ -69,7 +70,7 @@ API_URL="$2"
|
|
|
69
70
|
API_REF="$3"
|
|
70
71
|
|
|
71
72
|
[ -n "$API_URL" ] || die "The api repository URL is empty - the uniform reads the platform SSOT from that checkout, and this script does not guess where it lives. Expected: the URL the job builds from CI_JOB_TOKEN. Fix: pass it as the second argument." 2
|
|
72
|
-
[ -n "$API_REF" ] || die "The api ref is empty - which state of the platform this deploy is measured against is a decision, not a default. Expected: the
|
|
73
|
+
[ -n "$API_REF" ] || die "The api ref is empty - which state of the platform this deploy is measured against is a decision, not a default. Expected: the ref the calling job declares (API_UNIFORM_REF_CONTINUOUS for validate-uniform, API_UNIFORM_REF_DEPLOY for deploy-production). Fix: pass it as the third argument." 2
|
|
73
74
|
[ -d "$SERVICE_ROOT" ] || die "Service root not found - '$SERVICE_ROOT' is not an existing directory. Expected: the checkout being deployed. Fix: pass \$CI_PROJECT_DIR." 2
|
|
74
75
|
|
|
75
76
|
SERVICE_ROOT=$(cd "$SERVICE_ROOT" && pwd)
|