@onlineapps/conn-orch-validator 7.0.0 → 8.1.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +2582 -2
  2. package/README.md +1038 -4
  3. package/docs/DESIGN.md +3 -1
  4. package/manifests/biz-service.manifest.json +658 -0
  5. package/manifests/library.manifest.json +324 -0
  6. package/package.json +12 -6
  7. package/src/CookbookTestRunner.js +408 -101
  8. package/src/CookbookTestUtils.js +7 -8
  9. package/src/ServiceReadinessValidator.js +10 -35
  10. package/src/ValidationOrchestrator.js +219 -71
  11. package/src/cli/biz-ci-gate.js +176 -33
  12. package/src/cli/oa-lint-scripts.js +221 -0
  13. package/src/cli/oa-sync-template.js +1020 -0
  14. package/src/cli/oa-validate.js +474 -0
  15. package/src/helpers/README.md +2 -1
  16. package/src/helpers/createServiceReadinessTests.js +60 -4
  17. package/src/index.js +33 -3
  18. package/src/lint/scripts/lintScripts.js +298 -0
  19. package/src/manifest/checks/composeRunnerBlock.js +222 -0
  20. package/src/manifest/checks/composeShape.js +165 -0
  21. package/src/manifest/checks/contractBridge.js +181 -0
  22. package/src/manifest/checks/discoveryOrphan.js +50 -0
  23. package/src/manifest/checks/docsLintBridge.js +553 -0
  24. package/src/manifest/checks/fileAbsent.js +35 -0
  25. package/src/manifest/checks/gitTracked.js +204 -0
  26. package/src/manifest/checks/index.js +111 -0
  27. package/src/manifest/checks/libraryContext.js +226 -0
  28. package/src/manifest/checks/libraryDocs.js +75 -0
  29. package/src/manifest/checks/libraryPackage.js +272 -0
  30. package/src/manifest/checks/librarySource.js +274 -0
  31. package/src/manifest/checks/libraryTests.js +121 -0
  32. package/src/manifest/checks/libraryWorkspace.js +293 -0
  33. package/src/manifest/checks/readmeRegion.js +135 -0
  34. package/src/manifest/checks/scriptHeaders.js +79 -0
  35. package/src/manifest/checks/serviceConfig.js +390 -0
  36. package/src/manifest/checks/serviceConnectors.js +81 -0
  37. package/src/manifest/checks/serviceDb.js +388 -0
  38. package/src/manifest/checks/serviceFiles.js +754 -0
  39. package/src/manifest/checks/serviceIdentityRows.js +351 -0
  40. package/src/manifest/checks/serviceRuntime.js +295 -0
  41. package/src/manifest/checks/serviceScripts.js +213 -0
  42. package/src/manifest/deployabilitySignal.js +121 -0
  43. package/src/manifest/discovery.js +386 -0
  44. package/src/manifest/loadManifest.js +62 -0
  45. package/src/manifest/manifestShape.js +446 -0
  46. package/src/manifest/report.js +245 -0
  47. package/src/manifest/runManifest.js +449 -0
  48. package/src/manifest/serviceIdentity.js +140 -0
  49. package/src/manifest/walk.js +74 -0
  50. package/src/manifest/workspaceRoot.js +242 -0
  51. package/src/mocks/MockMQClient.js +13 -30
  52. package/src/mocks/MockRegistry.js +4 -2
  53. package/src/mocks/MockStorage.js +4 -2
  54. package/src/sync/docsRegion.js +463 -0
  55. package/src/sync/generatedRegion.js +228 -0
  56. package/src/sync/readmeLocation.js +182 -0
  57. package/src/sync/readmePointer.js +477 -0
  58. package/src/sync/serviceTemplate.js +583 -0
  59. package/src/sync/sharedEnv.js +162 -0
  60. package/src/sync/uniformFiles.js +474 -0
  61. package/src/utils/bizCiGateContract.js +131 -7
  62. package/src/utils/connectorContract.js +97 -7
  63. package/src/utils/cookbookFormat.js +81 -40
  64. package/src/utils/deployContract.js +140 -9
  65. package/src/utils/envContract.js +57 -1
  66. package/src/utils/handlerRef.js +181 -0
  67. package/src/utils/installContract.js +287 -41
  68. package/src/utils/libCompat.js +29 -7
  69. package/src/utils/migrationOrder.js +163 -0
  70. package/src/utils/preValidation.js +20 -7
  71. package/src/utils/setupDatabase.js +194 -13
  72. package/src/utils/testCoverageContract.js +539 -0
  73. package/src/utils/testNamespace.js +247 -23
  74. package/src/utils/throwawaySchema.js +207 -0
  75. package/src/validators/ServiceStructureValidator.js +2 -1
  76. package/templates/business-service/.dockerignore +42 -0
  77. package/templates/business-service/.gitlab-ci.yml +409 -0
  78. package/templates/business-service/Dockerfile +27 -0
  79. package/templates/business-service/README.md +213 -0
  80. package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
  81. package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
  82. package/templates/business-service/config/env-templates/shared.env +65 -0
  83. package/templates/business-service/config/service/config.json +14 -0
  84. package/templates/business-service/config/service/integration-contract.json +12 -0
  85. package/templates/business-service/config/service/operations.json +41 -0
  86. package/templates/business-service/docker-compose.production.yml +60 -0
  87. package/templates/business-service/docker-compose.yml +93 -0
  88. package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
  89. package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
  90. package/templates/business-service/docs/80-setup/README.md +18 -0
  91. package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
  92. package/templates/business-service/docs/README.md +18 -0
  93. package/templates/business-service/gitignore +42 -0
  94. package/templates/business-service/index.js +10 -0
  95. package/templates/business-service/init.sh +54 -0
  96. package/templates/business-service/jest.config.js +6 -0
  97. package/templates/business-service/package.json.template +31 -0
  98. package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
  99. package/templates/business-service/src/handlers/v3/echo.js +39 -0
  100. package/templates/business-service/tests/cookbooks/echo.json +36 -0
  101. package/templates/business-service/tests/unit/handler.test.js +78 -0
  102. package/src/WorkflowTestRunner.js +0 -402
@@ -0,0 +1,409 @@
1
+ # --- oa-ci v1
2
+ # Written by @onlineapps/conn-orch-validator (row G-CI of
3
+ # manifests/biz-service.manifest.json). The PLATFORM half of this pipeline: which
4
+ # pipelines run at all, how a service is built, how its image is identified (R5),
5
+ # how the digest reaches the deploy (R1/R2), that the installation document and
6
+ # the migrations still agree, the uniform gate that runs before the SSH step
7
+ # (confirmation biz-service-manifest 008) and the post-deploy gate that runs
8
+ # after it (confirmation deploy-gate-targets 003). npx oa-sync-template .gitlab-ci.yml
9
+ # --target . rewrites everything between these two markers and nothing outside
10
+ # them.
11
+ #
12
+ # Outside them is this repository's own: its test job - which database, which
13
+ # ci:gate:* steps and which artefacts its integration needs is a fact about the
14
+ # service - and whatever else it runs. The stages this block declares (test,
15
+ # build, secret-detection, deploy) cover those jobs too; measured over the eight
16
+ # biz repositories on 2026-09-11, none declares a fifth stage.
17
+ include:
18
+ - template: Jobs/Secret-Detection.gitlab-ci.yml
19
+
20
+ workflow:
21
+ name: "$CI_PROJECT_NAME — $CI_COMMIT_BRANCH [$CI_PIPELINE_SOURCE]"
22
+
23
+ stages:
24
+ - test
25
+ - build
26
+ - secret-detection
27
+ - deploy
28
+
29
+ variables:
30
+ # R5: one commit-sha scheme across the platform — the FULL sha, the way infra
31
+ # tags. Two lengths would mean two schemes and the release manifest needs one.
32
+ IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
33
+ IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
34
+
35
+ verify-installation-contract:
36
+ stage: test
37
+ image: alpine:latest
38
+ script:
39
+ - sh scripts/verify-installation-docs-sql-contract.sh
40
+ rules:
41
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
42
+ - if: $CI_COMMIT_BRANCH == "main"
43
+ - if: $CI_COMMIT_BRANCH == "production"
44
+
45
+ build:
46
+ stage: build
47
+ image: docker:24
48
+ services:
49
+ - docker:24-dind
50
+ script:
51
+ # A password on a command line stands in the runner's process table for the
52
+ # length of the build and in every trace that echoes the command. The deploy
53
+ # host's own login has read it from stdin since it was written; both ends of
54
+ # one login now agree.
55
+ - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
56
+ - docker build --target production -t $IMAGE -t $IMAGE_LATEST .
57
+ - docker push $IMAGE
58
+ - docker push $IMAGE_LATEST
59
+ # R1: the deploy target is the digest, and it exists only after the push.
60
+ - |
61
+ # --format is not honoured by the buildx on the runner: it prints the whole
62
+ # descriptor (Name/MediaType/Digest), so the digest would be captured
63
+ # multi-line and build.env rejected as an invalid dotenv artifact. Parse
64
+ # the Digest field.
65
+ BIZ_IMAGE_DIGEST=$(docker buildx imagetools inspect "$IMAGE" | awk '/^Digest:/ { print $2; exit }')
66
+ if ! printf '%s' "$BIZ_IMAGE_DIGEST" | grep -qE '^sha256:[0-9a-f]{64}$'; then
67
+ echo "[build] FATAL: no valid digest resolved for $IMAGE - got '$BIZ_IMAGE_DIGEST', expected sha256:<64 hex>. The deploy would have no immutable target. Fix: confirm the push succeeded and that buildx can read $CI_REGISTRY." >&2
68
+ exit 1
69
+ fi
70
+ echo "[build] $IMAGE -> $BIZ_IMAGE_DIGEST"
71
+ echo "BIZ_IMAGE_DIGEST=$BIZ_IMAGE_DIGEST" > build.env
72
+ artifacts:
73
+ reports:
74
+ dotenv: build.env
75
+ rules:
76
+ - if: $CI_COMMIT_BRANCH == "main"
77
+ - if: $CI_COMMIT_BRANCH == "production"
78
+
79
+ secret_detection:
80
+ stage: secret-detection
81
+ rules:
82
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
83
+ - if: $CI_COMMIT_BRANCH == "main"
84
+ - if: $CI_COMMIT_BRANCH == "production"
85
+
86
+ deploy-production:
87
+ stage: deploy
88
+ # The uniform runs here (confirmation biz-service-manifest 008), so the job
89
+ # needs the runtime the pinned engine declares — the same major the test stage
90
+ # and the Dockerfile carry, which is what R3 of the deploy contract compares.
91
+ image: node:24-alpine
92
+ variables:
93
+ # The engine answers the rows that read the platform SSOT only for a service
94
+ # lying at <root>/api_biz/<service> beside <root>/api. GitLab decides where a
95
+ # project is checked out, so the job ASKS for the one place that IS that
96
+ # layout — no symlink (api rule: No Symlinks) and no second copy of this
97
+ # repository, which would leave two trees and no way to tell which one the
98
+ # verdict was about. Needs [runners.custom_build_dir] enabled on the runner.
99
+ GIT_CLONE_PATH: $CI_BUILDS_DIR/oa-uniform/api_biz/__SERVICE_NAME__
100
+ # Where the platform SSOT is read from, and which state of it a production
101
+ # deploy is measured against. Read access is granted by the api project's CI
102
+ # job-token allowlist — the owner's step, outside any repository
103
+ # (confirmation biz-service-manifest 008 point 2).
104
+ API_PROJECT_PATH: onlineapps/oadrive/infra-mono
105
+ API_UNIFORM_REF: production
106
+ # Which checks judge this deploy. The gate has no default target: the job
107
+ # declares what it is measured by (confirmation deploy-gate-targets 002),
108
+ # and the name is the service's registry identity — the one
109
+ # api/config/services.json carries and the contract files its target under.
110
+ DEPLOY_GATE_TARGETS: "biz:__REGISTRY_NAME__"
111
+ before_script:
112
+ # Preconditions first, before anything is installed or deployed
113
+ # (automation-gates.md §1 requirement 4). The post-deploy gate reads the
114
+ # heartbeat projection on the INFRA box as its own read-only account
115
+ # (confirmation deploy-gate-targets 003 point 3); without that identity this
116
+ # job could deploy a service and then not verify it, which is exactly the
117
+ # false guarantee §5 of the same rule names. So it refuses instead, and
118
+ # there is no branch on which the gate is skipped (§1 requirement 5).
119
+ - |
120
+ # gate-identity-check
121
+ missing=""
122
+ [ -n "${GATE_INFRA_HOST:-}" ] || missing="$missing GATE_INFRA_HOST"
123
+ [ -n "${GATE_INFRA_USER:-}" ] || missing="$missing GATE_INFRA_USER"
124
+ [ -n "${GATE_INFRA_SSH_PRIVATE_KEY:-}" ] || missing="$missing GATE_INFRA_SSH_PRIVATE_KEY"
125
+ if [ -n "$missing" ]; then
126
+ echo "[deploy] FATAL: the post-deploy gate has no identity on the infra box - missing CI variable(s):$missing. Expected: the group-level variables GATE_INFRA_HOST, GATE_INFRA_USER and GATE_INFRA_SSH_PRIVATE_KEY (base64 ed25519 private key of the gate-reader account). Fix: set them in GitLab > Group > Settings > CI/CD > Variables, and install the account on the infra box with api/scripts/provision/gate-reader-install.sh (confirmation deploy-gate-targets 003 point 3)." >&2
127
+ exit 1
128
+ fi
129
+ # end gate-identity-check
130
+ - |
131
+ # host-key-check
132
+ # An ssh that accepts whatever key answers on an address hands the deploy
133
+ # key, the registry password and the gate-reader key to whoever answered.
134
+ # Both boxes are therefore pinned from a CI variable, and a missing pin
135
+ # stops the job HERE, before anything is installed and before the first
136
+ # connection - never a fallback to accepting the unknown
137
+ # (automation-gates.md §1 requirements 4 and 5, §2).
138
+ if [ -z "${DEPLOY_HOST_KEY:-}" ]; then
139
+ echo "[deploy] Missing CI variable - DEPLOY_HOST_KEY is the ssh-keyscan line of the biz box DEPLOY_HOST ($DEPLOY_HOST), which this job deploys to over ssh. Fix: on a machine that already trusts that box run 'ssh-keyscan -t ed25519 $DEPLOY_HOST' and set its whole output line as DEPLOY_HOST_KEY in GitLab > Group > Settings > CI/CD > Variables." >&2
140
+ exit 1
141
+ fi
142
+ if [ -z "${GATE_INFRA_HOST_KEY:-}" ]; then
143
+ echo "[deploy] Missing CI variable - GATE_INFRA_HOST_KEY is the ssh-keyscan line of the infra box GATE_INFRA_HOST ($GATE_INFRA_HOST), which the post-deploy gate reads the heartbeat projection from. Fix: on a machine that already trusts that box run 'ssh-keyscan -t ed25519 $GATE_INFRA_HOST' and set its whole output line as GATE_INFRA_HOST_KEY in GitLab > Group > Settings > CI/CD > Variables." >&2
144
+ exit 1
145
+ fi
146
+ # end host-key-check
147
+ # openssh-client and git for the two SSH steps and the api checkout the
148
+ # uniform reads; bash, jq and curl for the post-deploy gate — it declares
149
+ # bash>=4 (declare -A), reads the contract with jq and submits a workflow
150
+ # smoke with curl. No docker: in remote mode every docker call is carried to
151
+ # the box that holds the fact, over ssh.
152
+ - apk add --no-cache openssh-client git bash jq curl
153
+ - mkdir -p ~/.ssh
154
+ - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/deploy_key
155
+ - chmod 600 ~/.ssh/deploy_key
156
+ - echo "$GATE_INFRA_SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/gate_infra_key
157
+ - chmod 600 ~/.ssh/gate_infra_key
158
+ # The identity of the two boxes, pinned. Written before the config that
159
+ # demands it, so no ordering makes the demand unsatisfiable.
160
+ - echo "$DEPLOY_HOST_KEY" > ~/.ssh/known_hosts
161
+ - echo "$GATE_INFRA_HOST_KEY" >> ~/.ssh/known_hosts
162
+ - chmod 644 ~/.ssh/known_hosts
163
+ # Two boxes, two identities, and neither key is ever offered to the other
164
+ # box: the deploy key opens the biz box, the gate-reader key the infra box.
165
+ # ssh_config keeps the FIRST value it finds for a keyword, so the host
166
+ # blocks come before the catch-all and the catch-all carries only the
167
+ # host-key policy - which is where it belongs rather than on a command line:
168
+ # the post-deploy gate this job runs at the end (see its invocation below)
169
+ # opens its own ssh connections inside the script, and takes no ssh flags
170
+ # from here, so a policy written per-command would cover the deploy step and
171
+ # leave the gate connecting to anything that answered.
172
+ - |
173
+ {
174
+ echo "Host $DEPLOY_HOST"
175
+ echo " IdentityFile ~/.ssh/deploy_key"
176
+ echo " IdentitiesOnly yes"
177
+ echo "Host $GATE_INFRA_HOST"
178
+ echo " IdentityFile ~/.ssh/gate_infra_key"
179
+ echo " IdentitiesOnly yes"
180
+ echo "Host *"
181
+ echo " StrictHostKeyChecking yes"
182
+ echo " UserKnownHostsFile ~/.ssh/known_hosts"
183
+ } > ~/.ssh/config
184
+ dependencies:
185
+ - build
186
+ script:
187
+ # The uniform of this checkout, complete, BEFORE anything is deployed
188
+ # (confirmation biz-service-manifest 006 point 3, refined by 008): deploy is
189
+ # confirmation, never discovery. One blocking finding, or a run that could
190
+ # not look everywhere, and nothing reaches the box. There is no flag and no
191
+ # variable that turns this off (automation-gates.md §1 requirement 5).
192
+ - npm ci
193
+ - scripts/verify-deploy-uniform.sh "$CI_PROJECT_DIR" "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/${API_PROJECT_PATH}.git" "$API_UNIFORM_REF"
194
+ # R1: refuse to deploy without the immutable target the build stage published.
195
+ - |
196
+ if [ -z "${BIZ_IMAGE_DIGEST:-}" ]; then
197
+ echo "[deploy] FATAL: BIZ_IMAGE_DIGEST is empty - the build stage publishes it as a dotenv artifact. Refusing to deploy without an immutable image target." >&2
198
+ exit 1
199
+ fi
200
+ - echo "Deploying $BIZ_IMAGE_DIGEST to $DEPLOY_HOST ($CI_ENVIRONMENT_URL)"
201
+ # The read-only api clone the uniform step already made beside this checkout.
202
+ # Derived from CI_PROJECT_DIR rather than spelled out a second time: the
203
+ # layout is declared once, in GIT_CLONE_PATH, so the two cannot drift apart.
204
+ # TWO steps read it: the migration runner streamed to the box below, and the
205
+ # post-deploy gate at the end of this job.
206
+ - API_CHECKOUT="$(dirname "$(dirname "$CI_PROJECT_DIR")")/api"
207
+ # WHICH SCHEMA, IF ANY (confirmation db-migrations-first-deploy 001, phase 2).
208
+ # Read from the one declaration there is - this service's own installation
209
+ # contract - and read HERE, on the runner: the contract is this repository's,
210
+ # jq is in this image, and the box is not asked to carry a JSON parser for two
211
+ # values. A service that declares no `database` block has no schema to
212
+ # migrate, and the step below says that out loud rather than passing over it
213
+ # in silence (automation-gates.md 5: a step that checked nothing must never
214
+ # look like one that checked).
215
+ - |
216
+ CONTRACT="$CI_PROJECT_DIR/config/service/integration-contract.json"
217
+ if [ ! -f "$CONTRACT" ]; then
218
+ echo "[deploy] FATAL: no installation contract at $CONTRACT - this deploy cannot tell whether the service has a schema to migrate, and guessing is not an option. Fix: commit config/service/integration-contract.json (the uniform manifest requires it of every biz repository)." >&2
219
+ exit 1
220
+ fi
221
+ BIZ_DB_SCHEMA="$(jq -r '.database.schema // empty' "$CONTRACT")"
222
+ BIZ_DB_MIGRATIONS="$(jq -r '.database.migrations // empty' "$CONTRACT")"
223
+ if [ -n "$BIZ_DB_SCHEMA" ] && [ -z "$BIZ_DB_MIGRATIONS" ]; then
224
+ echo "[deploy] FATAL: $CONTRACT declares database.schema ($BIZ_DB_SCHEMA) and no database.migrations - the deploy would not know which files to apply. Fix: declare the glob of the directory that holds them (api/docs/biz/70-contracts/database-contract.md)." >&2
225
+ exit 1
226
+ fi
227
+ if [ -n "$BIZ_DB_MIGRATIONS" ]; then
228
+ # The runner applies a DIRECTORY and does not descend into it; the
229
+ # contract declares the glob (migrations/*.sql, migrations/BASELINE/*.sql),
230
+ # so the directory is its dirname - the same reading the runbook gives it
231
+ # (api/docs/setup/INSTALL.md, applying a business schema).
232
+ BIZ_DB_MIGRATIONS="$(dirname "$BIZ_DB_MIGRATIONS")"
233
+ fi
234
+ # DEPLOY_PATH / DEPLOY_HOST / DEPLOY_USER are CI variables: the box decides
235
+ # where its checkout lives, and no path is baked into this file.
236
+ #
237
+ # The registry password travels on STDIN and nowhere else. As an argument of
238
+ # the remote `bash -s` it stood in the deploy host's process table for the
239
+ # whole deploy, readable by every account on that box, and in this runner's
240
+ # argv besides. So the stream the remote shell reads is the password's line
241
+ # first and the script after it: the remote reads that one line, exports it,
242
+ # and hands the rest of the stream to `bash -s` - ssh's stdin is a socket
243
+ # there, so `read` consumes exactly the line and no more.
244
+ #
245
+ # The shell the remote runs is ASSEMBLED from three parts, in this order: the
246
+ # password line, the shell options, and then the platform's own MariaDB
247
+ # migration runner followed by the body below. The runner is carried rather
248
+ # than copied - it is api/scripts/lib/mariadb-migrations.sh out of the clone
249
+ # the uniform step made, so the box applies the same shell the runbook applies
250
+ # by hand in phase 1, and no second implementation of it exists anywhere
251
+ # (change-discipline.md - One rail per concern). The options are set once, for
252
+ # the whole assembled stream, which is why they are no longer the first line
253
+ # of the body.
254
+ - |
255
+ {
256
+ printf '%s\n' "$CI_REGISTRY_PASSWORD"
257
+ printf '%s\n' "set -euo pipefail"
258
+ cat "$API_CHECKOUT/scripts/lib/mariadb-migrations.sh"
259
+ cat
260
+ } <<'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'"
261
+ DEPLOY_PATH="$1"
262
+ REGISTRY="$2"
263
+ REGISTRY_USER="$3"
264
+ PROJECT_SLUG="$4"
265
+ export BIZ_IMAGE_DIGEST="$5"
266
+ PROJECT_PATH="$6"
267
+ # Empty when the installation contract declares no `database` block - the
268
+ # only thing that turns the migration step below on or off.
269
+ DB_SCHEMA="$7"
270
+ DB_MIGRATIONS_DIR="$8"
271
+ # This service's own env file on the box, the per-machine half of the two
272
+ # the compose file names (../shared.env is the host's). It carries the
273
+ # migration account - the SAME account the service connects as.
274
+ SERVICE_ENV="config/env-active/__SERVICE_NAME__.env"
275
+ # The host-level env file of the biz box, one per box and referenced rather
276
+ # than copied (server-topology 004) - the same path docker-compose.production.yml
277
+ # names.
278
+ HOST_ENV="../shared.env"
279
+ # REGISTRY_PASSWORD arrives in the environment, from the first line of this
280
+ # script's own stdin. Empty means the runner had no CI_REGISTRY_PASSWORD:
281
+ # the pull would fail later with an authentication error that names nothing.
282
+ if [ -z "${REGISTRY_PASSWORD:-}" ]; then
283
+ echo "[deploy] FATAL: REGISTRY_PASSWORD is empty - it is read from the first line of this script's own stdin, before bash -s. Expected: CI_REGISTRY_PASSWORD set on the runner. Fix: check the job's CI variables; refusing to deploy without a registry login." >&2
284
+ exit 1
285
+ fi
286
+ DOCKER_CONFIG_DIR="$HOME/.docker-ci/${PROJECT_SLUG}"
287
+
288
+ # Onboarding a new biz service must not require a step on the server —
289
+ # owner decision api/docs/governance/confirmations/server-topology.md 004.
290
+ # The first deploy therefore creates the checkout itself; the deploy user
291
+ # holds a read key for every biz repository.
292
+ if [ ! -d "$DEPLOY_PATH/.git" ]; then
293
+ echo "[deploy] $DEPLOY_PATH holds no checkout - cloning git@gitlab.com:${PROJECT_PATH}.git"
294
+ git clone "git@gitlab.com:${PROJECT_PATH}.git" "$DEPLOY_PATH"
295
+ fi
296
+
297
+ cd "$DEPLOY_PATH"
298
+ # R2: reset, never merge. A local modification on the server would turn a
299
+ # merge into a conflict and abort the deploy halfway.
300
+ git fetch origin production
301
+ git reset --hard origin/production
302
+ mkdir -p "$DOCKER_CONFIG_DIR"
303
+ echo "$REGISTRY_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
304
+ echo "[deploy] pulling $BIZ_IMAGE_DIGEST"
305
+ docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml pull
306
+
307
+ # ─── Migrations: after the pull, before the switch ───────────────
308
+ # Owner decision api/docs/governance/confirmations/db-migrations-first-deploy.md
309
+ # 001, phase 2. The ordering IS the safety: at this point the new image is
310
+ # on the box and the OLD container is still serving, so a migration that
311
+ # fails stops the deploy with the previous version running and the new one
312
+ # never started. Applying after the switch would mean the new code meets a
313
+ # schema it does not have; applying before the pull would mean a schema
314
+ # change for an image that might not even be pullable.
315
+ #
316
+ # The account is the SERVICE's, never root: its grants cover its own schema
317
+ # and nothing else, so a migration that reaches sideways into a neighbouring
318
+ # database is refused by the server rather than politely applied
319
+ # (db-accounts-per-service 001). This step CREATES NOTHING - schema and
320
+ # account are an operator's step in the runbook, phase 1, and a deploy that
321
+ # provisioned what it found missing would both grant itself those privileges
322
+ # and hide a wrongly provisioned box.
323
+ if [ -z "$DB_SCHEMA" ]; then
324
+ echo "[deploy] migrations NOT APPLICABLE (no database block) - config/service/integration-contract.json declares no database, so this service has no schema to migrate."
325
+ else
326
+ # The two env files the compose names, in the order it names them: the
327
+ # host-level one first, this service's own second, so the same value wins
328
+ # here that wins inside the container. Reading only one of them would
329
+ # make this step disagree with the service it migrates for, depending on
330
+ # which file the box happens to carry DB_HOST in.
331
+ for env_file in "$HOST_ENV" "$SERVICE_ENV"; do
332
+ if [ ! -f "$env_file" ]; then
333
+ 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
334
+ exit 1
335
+ fi
336
+ set -a
337
+ . "$env_file"
338
+ set +a
339
+ done
340
+ missing=""
341
+ [ -n "${MARIADB_MIGRATION_USER:-}" ] || missing="$missing MARIADB_MIGRATION_USER"
342
+ [ -n "${MARIADB_MIGRATION_PASSWORD:-}" ] || missing="$missing MARIADB_MIGRATION_PASSWORD"
343
+ [ -n "${DB_HOST:-}" ] || missing="$missing DB_HOST"
344
+ [ -n "${DB_PORT:-}" ] || missing="$missing DB_PORT"
345
+ if [ -n "$missing" ]; then
346
+ 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
347
+ exit 1
348
+ fi
349
+ # The precondition confirmation 001 demands before any migration: the
350
+ # account opens the schema, or this deploy stops and names the runbook.
351
+ if ! mariadb_migrations_can_open_over_tcp "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"; then
352
+ echo "[deploy] Schema or account missing - '$MARIADB_MIGRATION_USER' cannot open '$DB_SCHEMA' at $DB_HOST:$DB_PORT. Expected: the schema and the account, with grants on ${DB_SCHEMA}.* and nothing else, provisioned once by an operator. Fix: run the business-schema step of api/docs/setup/INSTALL.md on the infra box, then re-run this deploy. This step never creates either: provisioning is a decision, not a deploy side effect." >&2
353
+ exit 1
354
+ fi
355
+ # The tracker is the caller's state and it lives in the checkout on this
356
+ # box, under the gitignored config/runtime/ - same file name and same flat
357
+ # format the runbook writes in phase 1, so a schema already carried
358
+ # forward is skipped rather than applied twice (confirmation 001:
359
+ # "fáze 2 nesmí změnit sémantiku trackeru").
360
+ mkdir -p config/runtime
361
+ TRACKER="config/runtime/applied-migrations-${DB_SCHEMA}.txt"
362
+ touch "$TRACKER"
363
+ apply_mariadb_migrations_over_tcp "$DB_MIGRATIONS_DIR" "$TRACKER" "$DB_HOST" "$DB_PORT" "$DB_SCHEMA"
364
+ # The runner logs each file it applies; what this line adds is the state
365
+ # that outlives the deploy. The runner's own counter of files it passed
366
+ # over is deliberately not echoed: no word that reads as a bypass belongs
367
+ # anywhere in a deploy job, and the gate that keeps this job free of one
368
+ # judges by the word, not by the intent.
369
+ echo "[deploy] migrations $DB_SCHEMA: $MARIADB_MIGRATIONS_APPLIED applied now, $(wc -l < "$TRACKER" | tr -d " ") recorded in $DEPLOY_PATH/$TRACKER"
370
+ fi
371
+
372
+ docker --config "$DOCKER_CONFIG_DIR" compose -f docker-compose.production.yml up -d
373
+ REMOTE
374
+ # The deploy is not finished until the platform's own post-deploy gate says
375
+ # the service is alive: the heartbeat projection is present, UP and fresh,
376
+ # and the container's RestartCount has not moved over the window
377
+ # (confirmations deploy-gate-targets 003, deploy-gate-restart-count 001).
378
+ # The two facts sit on two boxes and this runner is a third, so the gate
379
+ # runs in remote mode — same script, same contract, same verdicts as a local
380
+ # run, only reading over ssh.
381
+ - |
382
+ # The gate runs out of the same read-only api clone the migration runner
383
+ # was streamed from; API_CHECKOUT is derived once, above.
384
+ bash "$API_CHECKOUT/scripts/run-post-deploy-gate.sh" \
385
+ --target "$DEPLOY_GATE_TARGETS" \
386
+ --biz-host "$DEPLOY_USER@$DEPLOY_HOST" \
387
+ --infra-host "$GATE_INFRA_USER@$GATE_INFRA_HOST" \
388
+ --contract "$API_CHECKOUT/config/deploy-smoke-contract.json"
389
+ environment:
390
+ name: production
391
+ url: https://api.onlineapps.cz
392
+ rules:
393
+ - if: $CI_COMMIT_BRANCH == "production"
394
+ # --- end oa-ci v1
395
+
396
+ test:
397
+ stage: test
398
+ image: node:24-alpine
399
+ variables:
400
+ RABBITMQ_URL: "amqp://localhost:5672"
401
+ REGISTRY_URL: "http://localhost:33100"
402
+ NODE_ENV: "test"
403
+ script:
404
+ - npm ci
405
+ - npm run test:ci
406
+ rules:
407
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
408
+ - if: $CI_COMMIT_BRANCH == "main"
409
+ - if: $CI_COMMIT_BRANCH == "production"
@@ -0,0 +1,27 @@
1
+ # === Production stage (build with: --target production) ===
2
+ FROM node:24-alpine AS production
3
+ WORKDIR /app
4
+ COPY package*.json ./
5
+ RUN npm ci --omit=dev
6
+ COPY . .
7
+ # The manifest conformance check, inside the image that would be deployed
8
+ # (confirmation biz-service-manifest 001 §3.1: "The same check runs in the
9
+ # Dockerfile production stage, so an image with a finding is never built").
10
+ #
11
+ # It is the CLI of @onlineapps/conn-orch-validator, which every service declares
12
+ # under `dependencies` — measured across all eight biz repositories on
13
+ # 2026-09-09 — so `npm ci --omit=dev` above installs it and this line needs
14
+ # nothing the image does not already carry.
15
+ #
16
+ # The rows that read a platform file (the template, api/config/shared-env.json,
17
+ # api/.nvmrc) report NOT RUN here and say so by name: an image has no workspace
18
+ # above it. What runs is every row the repository itself answers, and one
19
+ # finding of severity boot or deploy exits non-zero and fails the build.
20
+ RUN node node_modules/@onlineapps/conn-orch-validator/src/cli/oa-validate.js .
21
+ CMD ["node", "index.js"]
22
+
23
+ # === Development stage (default) ===
24
+ FROM node:24-alpine
25
+ WORKDIR /app
26
+ COPY package*.json ./
27
+ CMD ["npm", "start"]
@@ -0,0 +1,213 @@
1
+ <!-- BEGIN GENERATED: biz-service-uniform — regenerate: npx oa-sync-template readme-uniform --target . -->
2
+ Uniform: [biz-service](./node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json)
3
+
4
+ Every `api_biz/*/package.json` that `api/config/services.json` lists wears it (`U-ORPHAN`, `U-IDENTITY`).
5
+
6
+ Duty sections that apply:
7
+
8
+ - `files`: F-INIT, F-JEST, F-RUNNER, F-GITIGNORE, F-DOCKERIGNORE, G-PROD, G-CI, G-PROD-IMAGE, G-README, G-SETUP, G-SETUP-INSTALL, G-SETUP-MATRIX, G-SETUP-VALIDATION, X-IGNORED, X-HOOKS, X-PREVAL, X-DB-CONFIG
9
+ - `scripts`: S-TEST, S-ALL-C, S-INT-C, S-COOKBOOKS, S-HOST, S-HOOKS
10
+ - `tooling`: S-SCRIPTS
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
14
+ - `docs`: C-LINT, D-PORT, D-NPM, D-SCRIPT, D-RETIRED, D-HEADER, D-LINT
15
+
16
+ Paths a duty owns:
17
+
18
+ - `.dockerignore`: F-DOCKERIGNORE (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/gitignore`)
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`)
21
+ - `README.md`: G-README (from `./node_modules/@onlineapps/conn-orch-validator/manifests/biz-service.manifest.json`)
22
+ - `config/biz-docs-lint.tree.json`: C-LINT
23
+ - `config/env-templates`: C-ENV, D-DB-ACCOUNT
24
+ - `config/env-templates/shared.env`: G-SHARED-ENV (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/config/env-templates/shared.env`)
25
+ - `config/service/config.json`: C-SERVICE
26
+ - `config/service/integration-contract.json`: C-CONTRACT, D-DB-COLLATION
27
+ - `config/service/operations.json`: C-OPS
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
30
+ - `docs/80-setup/`: G-SETUP
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
+ - `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`)
33
+ - `docs/80-setup/VALIDATION.md`: G-SETUP-VALIDATION (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/docs/80-setup/VALIDATION.md`)
34
+ - `hooks/pre-commit`: X-HOOKS
35
+ - `init.sh`: F-INIT (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/init.sh`)
36
+ - `jest.config.js`: F-JEST (from `./node_modules/@onlineapps/conn-orch-validator/templates/business-service/jest.config.js`)
37
+ - `migrations/`: D-DB-PACKAGE, D-DB-NAMING, D-DB-HEADERS
38
+ - `migrations/README.md`: D-DB-README
39
+ - `scripts/`: S-SCRIPTS
40
+ - `scripts/run-pre-validation.js`: X-PREVAL
41
+ - `src/config/database.js`: X-DB-CONFIG
42
+ <!-- END GENERATED: biz-service-uniform -->
43
+
44
+ # __SERVICE_NAME__
45
+
46
+ __SERVICE_DESCRIPTION__
47
+
48
+ ## Quick Start
49
+
50
+ ```bash
51
+ # 1. Copy env templates
52
+ cp config/env-templates/shared.env config/env-active/shared.env
53
+ cp config/env-templates/service.env config/env-active/__SERVICE_NAME__.env
54
+
55
+ # 2. Edit config/env-active/ files with actual values
56
+
57
+ # 3. Start (dev)
58
+ docker compose up -d --build
59
+
60
+ # 4. Start (production) — the image is pinned by digest, so it is PULLED, never
61
+ # built here: `docker build` refuses a tag that carries a digest. CI publishes
62
+ # the value; a manual run has to name the digest it wants.
63
+ BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml pull
64
+ BIZ_IMAGE_DIGEST=sha256:… docker compose -f docker-compose.production.yml up -d
65
+ ```
66
+
67
+ On the production biz box the second env file the compose references is
68
+ `../shared.env` — ONE host-level file per box, at
69
+ `/var/www/html/oadrive/api_biz/shared.env`. It is rendered on the infra box by
70
+ `api/scripts/provision/render-biz-shared-env.sh` and carried over by the
71
+ operator; this compose references it and never copies it (owner decision
72
+ `api/docs/governance/confirmations/server-topology.md` 004 and 005).
73
+ The network there is the external `biz_network`, also created by role-biz. The
74
+ dev arrangement above is a single host and keeps its own
75
+ `config/env-active/shared.env`.
76
+
77
+ ## Structure
78
+
79
+ ```
80
+ config/
81
+ ├── service/ # Service metadata (config.json, operations.json)
82
+ ├── env-templates/ # Versioned env templates
83
+ └── env-active/ # Runtime env (gitignored)
84
+ src/
85
+ └── handlers/v3/ # One module per operation; the handler: field points here
86
+ tests/
87
+ ├── unit/
88
+ └── cookbooks/ # Tier-1 cookbooks, one per operation
89
+ index.js # Entry point (bootstrap)
90
+ ```
91
+
92
+ There is **no** `src/app.js`, **no** `src/routes/` and no HTTP framework in
93
+ `package.json`: a biz container binds no port, and an operation is reached
94
+ through the `handler:` field in `operations.json` — ADR 0005,
95
+ `api/docs/biz/80-decisions/0005-no-http-in-biz-containers.md`, with the testable
96
+ form of it in `api/docs/biz/00-model/service-shape.md` § No-HTTP tests. Business
97
+ logic goes into `src/handlers/v3/`, the layout `api_biz/hello-service/src/` uses
98
+ — see `api/docs/biz/60-templates/service-template.md` § Expected Structure.
99
+ (Paths are relative to the workspace root, because this README is copied into a
100
+ sibling repository where a link relative to `api/` would not resolve.)
101
+
102
+ ## The example operation
103
+
104
+ The template ships one working operation, `echo`, so a new service starts from a
105
+ shape that already passes. Three files describe it, and replacing the operation
106
+ means replacing all three together:
107
+
108
+ | File | States |
109
+ |---|---|
110
+ | `config/service/operations.json` | the contract — `handler`, `bundle_scope`, `mutates`, `input`/`output` schemas |
111
+ | `src/handlers/v3/echo.js` | the behaviour — `echo(input, ctx)`, returning a value |
112
+ | `tests/cookbooks/echo.json` | the Tier-1 assertion — calls the operation and asserts the output BY VALUE |
113
+
114
+ `echo` is deliberately total and deterministic, which is what lets the cookbook
115
+ assert `length` equals `8` instead of merely asserting that something came back.
116
+ Keep that property when you replace it: a cookbook that asserts only truthiness
117
+ proves the dispatch worked and nothing about the result
118
+ (`.claude/rules/architecture-principles.md` §10a).
119
+
120
+ ## Testing
121
+
122
+ Suites run **in a container**, in the one-shot test runner this service ships
123
+ beside itself (`__CONTAINER_NAME___tests` in `docker-compose.yml`). It has its
124
+ own memory budget, so a heavy suite can no longer be killed together with the
125
+ service, and the `test` profile keeps it out of a plain `docker compose up`.
126
+
127
+ ```bash
128
+ npm run test:container # unit suite, in the runner
129
+ npm run test:all:container # everything test:all chains, in the runner
130
+ ```
131
+
132
+ Each wraps one command — `docker compose run --rm __CONTAINER_NAME___tests npm run <suite>`.
133
+
134
+ **Rebuilding the runner takes the profile with it.** `profiles: [test]` keeps the
135
+ runner out of a plain `docker compose up`, and out of a plain build for the same
136
+ reason — measured 2026-09-14: `docker compose build --dry-run` builds one image,
137
+ `docker compose --profile test build --dry-run` builds two. So after a change to
138
+ the `Dockerfile` or to the dependencies:
139
+
140
+ ```bash
141
+ docker compose --profile test build
142
+ ```
143
+
144
+ Without it the next `npm run test:container` runs the image the runner was last
145
+ built from, and a suite then passes or fails on code that is no longer in this
146
+ repository — a green run that says nothing about what is committed.
147
+
148
+ `npm test` and `npm run test:unit` are what runs INSIDE that container; the
149
+ runner is the one rail a suite runs on (owner decision
150
+ `api/docs/governance/confirmations/biz-test-container.md` 001, and manifest rows
151
+ `S-ALL-C` and `S-HOST`). Running them on the host is not a second, quicker way
152
+ to the same answer: the host has neither the service's `env_file` nor its
153
+ network, so what fails there says nothing about the service — which is why no
154
+ `test:host` script exists and why the uniform forbids one.
155
+
156
+ ### Cookbooks (Tier-1)
157
+
158
+ ```bash
159
+ npm run test:cookbooks:container # in the runner, which has the env
160
+ ```
161
+
162
+ It wraps `npm run test:cookbooks`, the library command
163
+ (`oa-biz-ci-gate run-prevalidation`, manifest row `S-COOKBOOKS`), which is what
164
+ runs inside the container. The cookbook run dispatches real handlers under the
165
+ testing namespace, so it reads `TESTING_TENANT_ID` and `TESTING_WORKSPACE_ID`;
166
+ the runner has both from `env_file`, and the run fails by name if they are
167
+ missing. It writes `conn-runtime/validation-proof.json` — generated,
168
+ gitignored, and **not** what the platform trusts: the proof that counts is the
169
+ one ServiceWrapper writes on every boot
170
+ (`api/docs/biz/50-lifecycle/validation-responsibilities.md` § Data Flow).
171
+
172
+ This template ships **no** `tests/integration/`, and no script declaring one:
173
+ there is no behaviour here to exercise, and a declaration with nothing behind it
174
+ is the defect `.claude/rules/change-discipline.md` § "Removing something removes
175
+ its declaration" describes. Add the directory together with the first real
176
+ integration test — `.claude/rules/architecture-principles.md` §10a requires one
177
+ for every behavioural change, so the first handler brings its own.
178
+
179
+ ## Contract gates
180
+
181
+ A fresh scaffold passes `biz-ci-gate verify-contract` — deploy, installation,
182
+ env, test coverage and the library pins — with nothing added by hand:
183
+
184
+ ```bash
185
+ node node_modules/@onlineapps/conn-orch-validator/src/cli/biz-ci-gate.js verify-contract
186
+ ```
187
+
188
+ Two files are what makes that true, and both are copied and substituted with the
189
+ rest of the template: `config/service/integration-contract.json` (the connectors,
190
+ the integration minimum, and — once a handler reads one — the `env` block), and
191
+ `docs/80-setup/{INSTALL,PLATFORM_MATRIX,VALIDATION}.md`, the three installation
192
+ documents the contract requires of every repository
193
+ (`api/docs/standards/repository-installation-sql-contract.md`).
194
+
195
+ ## Memory limits
196
+
197
+ Two numbers, and they measure different things — the platform norm, recorded in
198
+ `api/docs/governance/confirmations/biz-memory-limits.md` 001:
199
+
200
+ | Where | Key | Value | Covers |
201
+ |---|---|---|---|
202
+ | service, dev **and** production | `deploy.resources.limits.memory` | `512M` | the resident service process, nothing else |
203
+ | test runner, dev/CI only | `mem_limit` | `1g` | one suite at a time (`--maxWorkers=1`) |
204
+
205
+ They are prescribed values, not figures derived for this service. The split is
206
+ the point: while one budget covered both, a test run was killed by the kernel
207
+ and took the service down with it — so raising the service limit is never the
208
+ fix for a suite that runs out of memory, and no suite may share the service's
209
+ cgroup.
210
+
211
+ Revise the service limit when this service exceeds 60% of it in operation,
212
+ measured the way the norm was: cgroup `memory.stat:anon`, sampled inside the
213
+ running container.