@endora-commerce/cli 0.100.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/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/bin/endora.d.ts +28 -0
- package/dist/bin/endora.d.ts.map +1 -0
- package/dist/bin/endora.js +926 -0
- package/dist/bin/endora.js.map +1 -0
- package/dist/check/estate.d.ts +189 -0
- package/dist/check/estate.d.ts.map +1 -0
- package/dist/check/estate.js +1037 -0
- package/dist/check/estate.js.map +1 -0
- package/dist/check/hosts.d.ts +25 -0
- package/dist/check/hosts.d.ts.map +1 -0
- package/dist/check/hosts.js +1347 -0
- package/dist/check/hosts.js.map +1 -0
- package/dist/check/index.d.ts +60 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +111 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/layout.d.ts +136 -0
- package/dist/check/layout.d.ts.map +1 -0
- package/dist/check/layout.js +262 -0
- package/dist/check/layout.js.map +1 -0
- package/dist/check/ledger.d.ts +98 -0
- package/dist/check/ledger.d.ts.map +1 -0
- package/dist/check/ledger.js +173 -0
- package/dist/check/ledger.js.map +1 -0
- package/dist/check/peer-owners.d.ts +116 -0
- package/dist/check/peer-owners.d.ts.map +1 -0
- package/dist/check/peer-owners.js +225 -0
- package/dist/check/peer-owners.js.map +1 -0
- package/dist/check/report.d.ts +33 -0
- package/dist/check/report.d.ts.map +1 -0
- package/dist/check/report.js +107 -0
- package/dist/check/report.js.map +1 -0
- package/dist/check/run.d.ts +147 -0
- package/dist/check/run.d.ts.map +1 -0
- package/dist/check/run.js +111 -0
- package/dist/check/run.js.map +1 -0
- package/dist/checks.d.ts +17 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +17 -0
- package/dist/checks.js.map +1 -0
- package/dist/dev/index.d.ts +83 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +298 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/generate/divergence.d.ts +38 -0
- package/dist/generate/divergence.d.ts.map +1 -0
- package/dist/generate/divergence.js +237 -0
- package/dist/generate/divergence.js.map +1 -0
- package/dist/generate/index.d.ts +90 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +369 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs/declaration.d.ts +18 -0
- package/dist/inputs/declaration.d.ts.map +1 -0
- package/dist/inputs/declaration.js +64 -0
- package/dist/inputs/declaration.js.map +1 -0
- package/dist/inputs/env-file.d.ts +73 -0
- package/dist/inputs/env-file.d.ts.map +1 -0
- package/dist/inputs/env-file.js +134 -0
- package/dist/inputs/env-file.js.map +1 -0
- package/dist/inputs/prompt.d.ts +21 -0
- package/dist/inputs/prompt.d.ts.map +1 -0
- package/dist/inputs/prompt.js +59 -0
- package/dist/inputs/prompt.js.map +1 -0
- package/dist/inputs/resolve.d.ts +163 -0
- package/dist/inputs/resolve.d.ts.map +1 -0
- package/dist/inputs/resolve.js +290 -0
- package/dist/inputs/resolve.js.map +1 -0
- package/dist/install/host.d.ts +27 -0
- package/dist/install/host.d.ts.map +1 -0
- package/dist/install/host.js +90 -0
- package/dist/install/host.js.map +1 -0
- package/dist/install/index.d.ts +173 -0
- package/dist/install/index.d.ts.map +1 -0
- package/dist/install/index.js +793 -0
- package/dist/install/index.js.map +1 -0
- package/dist/install/wizard.d.ts +144 -0
- package/dist/install/wizard.d.ts.map +1 -0
- package/dist/install/wizard.js +362 -0
- package/dist/install/wizard.js.map +1 -0
- package/dist/lib/admin-artefacts.d.ts +70 -0
- package/dist/lib/admin-artefacts.d.ts.map +1 -0
- package/dist/lib/admin-artefacts.js +354 -0
- package/dist/lib/admin-artefacts.js.map +1 -0
- package/dist/lib/admin-surfaces.d.ts +298 -0
- package/dist/lib/admin-surfaces.d.ts.map +1 -0
- package/dist/lib/admin-surfaces.js +669 -0
- package/dist/lib/admin-surfaces.js.map +1 -0
- package/dist/lib/delegated-composer.d.ts +85 -0
- package/dist/lib/delegated-composer.d.ts.map +1 -0
- package/dist/lib/delegated-composer.js +241 -0
- package/dist/lib/delegated-composer.js.map +1 -0
- package/dist/lib/divergence-artefacts.d.ts +305 -0
- package/dist/lib/divergence-artefacts.d.ts.map +1 -0
- package/dist/lib/divergence-artefacts.js +828 -0
- package/dist/lib/divergence-artefacts.js.map +1 -0
- package/dist/lib/divergence.d.ts +337 -0
- package/dist/lib/divergence.d.ts.map +1 -0
- package/dist/lib/divergence.js +1005 -0
- package/dist/lib/divergence.js.map +1 -0
- package/dist/lib/docs-artefacts.d.ts +395 -0
- package/dist/lib/docs-artefacts.d.ts.map +1 -0
- package/dist/lib/docs-artefacts.js +781 -0
- package/dist/lib/docs-artefacts.js.map +1 -0
- package/dist/lib/emitted-exports.d.ts +21 -0
- package/dist/lib/emitted-exports.d.ts.map +1 -0
- package/dist/lib/emitted-exports.js +96 -0
- package/dist/lib/emitted-exports.js.map +1 -0
- package/dist/lib/emitted-freshness.d.ts +112 -0
- package/dist/lib/emitted-freshness.d.ts.map +1 -0
- package/dist/lib/emitted-freshness.js +288 -0
- package/dist/lib/emitted-freshness.js.map +1 -0
- package/dist/lib/entity-index-artefact.d.ts +95 -0
- package/dist/lib/entity-index-artefact.d.ts.map +1 -0
- package/dist/lib/entity-index-artefact.js +210 -0
- package/dist/lib/entity-index-artefact.js.map +1 -0
- package/dist/lib/instance-build-inputs.d.ts +108 -0
- package/dist/lib/instance-build-inputs.d.ts.map +1 -0
- package/dist/lib/instance-build-inputs.js +165 -0
- package/dist/lib/instance-build-inputs.js.map +1 -0
- package/dist/lib/module-docs.d.ts +473 -0
- package/dist/lib/module-docs.d.ts.map +1 -0
- package/dist/lib/module-docs.js +711 -0
- package/dist/lib/module-docs.js.map +1 -0
- package/dist/lib/module-package-subpaths.d.ts +56 -0
- package/dist/lib/module-package-subpaths.d.ts.map +1 -0
- package/dist/lib/module-package-subpaths.js +223 -0
- package/dist/lib/module-package-subpaths.js.map +1 -0
- package/dist/lib/module-packages.d.ts +200 -0
- package/dist/lib/module-packages.d.ts.map +1 -0
- package/dist/lib/module-packages.js +580 -0
- package/dist/lib/module-packages.js.map +1 -0
- package/dist/lib/module-population.d.ts +129 -0
- package/dist/lib/module-population.d.ts.map +1 -0
- package/dist/lib/module-population.js +172 -0
- package/dist/lib/module-population.js.map +1 -0
- package/dist/lib/module-roots.d.ts +337 -0
- package/dist/lib/module-roots.d.ts.map +1 -0
- package/dist/lib/module-roots.js +586 -0
- package/dist/lib/module-roots.js.map +1 -0
- package/dist/lib/nested-checkouts.d.ts +33 -0
- package/dist/lib/nested-checkouts.d.ts.map +1 -0
- package/dist/lib/nested-checkouts.js +160 -0
- package/dist/lib/nested-checkouts.js.map +1 -0
- package/dist/lib/platform-root.d.ts +43 -0
- package/dist/lib/platform-root.d.ts.map +1 -0
- package/dist/lib/platform-root.js +134 -0
- package/dist/lib/platform-root.js.map +1 -0
- package/dist/lib/platform-surface.d.ts +235 -0
- package/dist/lib/platform-surface.d.ts.map +1 -0
- package/dist/lib/platform-surface.js +393 -0
- package/dist/lib/platform-surface.js.map +1 -0
- package/dist/lib/port-registrations.d.ts +223 -0
- package/dist/lib/port-registrations.d.ts.map +1 -0
- package/dist/lib/port-registrations.js +532 -0
- package/dist/lib/port-registrations.js.map +1 -0
- package/dist/lib/read-size.d.ts +154 -0
- package/dist/lib/read-size.d.ts.map +1 -0
- package/dist/lib/read-size.js +182 -0
- package/dist/lib/read-size.js.map +1 -0
- package/dist/lib/registration-owners.d.ts +79 -0
- package/dist/lib/registration-owners.d.ts.map +1 -0
- package/dist/lib/registration-owners.js +77 -0
- package/dist/lib/registration-owners.js.map +1 -0
- package/dist/lib/release-index.d.ts +53 -0
- package/dist/lib/release-index.d.ts.map +1 -0
- package/dist/lib/release-index.js +162 -0
- package/dist/lib/release-index.js.map +1 -0
- package/dist/lib/repeating-timers.d.ts +79 -0
- package/dist/lib/repeating-timers.d.ts.map +1 -0
- package/dist/lib/repeating-timers.js +189 -0
- package/dist/lib/repeating-timers.js.map +1 -0
- package/dist/lib/source-text.d.ts +34 -0
- package/dist/lib/source-text.d.ts.map +1 -0
- package/dist/lib/source-text.js +80 -0
- package/dist/lib/source-text.js.map +1 -0
- package/dist/lib/specifiers.d.ts +20 -0
- package/dist/lib/specifiers.d.ts.map +1 -0
- package/dist/lib/specifiers.js +130 -0
- package/dist/lib/specifiers.js.map +1 -0
- package/dist/lib/sql-tables.d.ts +166 -0
- package/dist/lib/sql-tables.d.ts.map +1 -0
- package/dist/lib/sql-tables.js +464 -0
- package/dist/lib/sql-tables.js.map +1 -0
- package/dist/lib/switchable-modules.d.ts +54 -0
- package/dist/lib/switchable-modules.d.ts.map +1 -0
- package/dist/lib/switchable-modules.js +104 -0
- package/dist/lib/switchable-modules.js.map +1 -0
- package/dist/lib/tailwind-sources.d.ts +136 -0
- package/dist/lib/tailwind-sources.d.ts.map +1 -0
- package/dist/lib/tailwind-sources.js +307 -0
- package/dist/lib/tailwind-sources.js.map +1 -0
- package/dist/lib/ui-layer.d.ts +54 -0
- package/dist/lib/ui-layer.d.ts.map +1 -0
- package/dist/lib/ui-layer.js +57 -0
- package/dist/lib/ui-layer.js.map +1 -0
- package/dist/lib/workspace-packages.d.ts +186 -0
- package/dist/lib/workspace-packages.d.ts.map +1 -0
- package/dist/lib/workspace-packages.js +351 -0
- package/dist/lib/workspace-packages.js.map +1 -0
- package/dist/new-instance/deploy.d.ts +211 -0
- package/dist/new-instance/deploy.d.ts.map +1 -0
- package/dist/new-instance/deploy.js +1381 -0
- package/dist/new-instance/deploy.js.map +1 -0
- package/dist/new-instance/docs-toolchain.d.ts +66 -0
- package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
- package/dist/new-instance/docs-toolchain.js +69 -0
- package/dist/new-instance/docs-toolchain.js.map +1 -0
- package/dist/new-instance/host.d.ts +124 -0
- package/dist/new-instance/host.d.ts.map +1 -0
- package/dist/new-instance/host.js +276 -0
- package/dist/new-instance/host.js.map +1 -0
- package/dist/new-instance/index.d.ts +118 -0
- package/dist/new-instance/index.d.ts.map +1 -0
- package/dist/new-instance/index.js +567 -0
- package/dist/new-instance/index.js.map +1 -0
- package/dist/new-instance/modules.d.ts +188 -0
- package/dist/new-instance/modules.d.ts.map +1 -0
- package/dist/new-instance/modules.js +392 -0
- package/dist/new-instance/modules.js.map +1 -0
- package/dist/new-instance/template.d.ts +505 -0
- package/dist/new-instance/template.d.ts.map +1 -0
- package/dist/new-instance/template.js +1886 -0
- package/dist/new-instance/template.js.map +1 -0
- package/dist/new-module/emit.d.ts +67 -0
- package/dist/new-module/emit.d.ts.map +1 -0
- package/dist/new-module/emit.js +1393 -0
- package/dist/new-module/emit.js.map +1 -0
- package/dist/new-module/host.d.ts +67 -0
- package/dist/new-module/host.d.ts.map +1 -0
- package/dist/new-module/host.js +224 -0
- package/dist/new-module/host.js.map +1 -0
- package/dist/new-module/index.d.ts +32 -0
- package/dist/new-module/index.d.ts.map +1 -0
- package/dist/new-module/index.js +193 -0
- package/dist/new-module/index.js.map +1 -0
- package/dist/new-module/spec.d.ts +188 -0
- package/dist/new-module/spec.d.ts.map +1 -0
- package/dist/new-module/spec.js +404 -0
- package/dist/new-module/spec.js.map +1 -0
- package/dist/new-module/text.d.ts +15 -0
- package/dist/new-module/text.d.ts.map +1 -0
- package/dist/new-module/text.js +22 -0
- package/dist/new-module/text.js.map +1 -0
- package/dist/new-storefront/dockerfile.d.ts +20 -0
- package/dist/new-storefront/dockerfile.d.ts.map +1 -0
- package/dist/new-storefront/dockerfile.js +131 -0
- package/dist/new-storefront/dockerfile.js.map +1 -0
- package/dist/new-storefront/gitignore.d.ts +23 -0
- package/dist/new-storefront/gitignore.d.ts.map +1 -0
- package/dist/new-storefront/gitignore.js +39 -0
- package/dist/new-storefront/gitignore.js.map +1 -0
- package/dist/new-storefront/index.d.ts +93 -0
- package/dist/new-storefront/index.d.ts.map +1 -0
- package/dist/new-storefront/index.js +329 -0
- package/dist/new-storefront/index.js.map +1 -0
- package/dist/new-storefront/npmrc.d.ts +98 -0
- package/dist/new-storefront/npmrc.d.ts.map +1 -0
- package/dist/new-storefront/npmrc.js +217 -0
- package/dist/new-storefront/npmrc.js.map +1 -0
- package/dist/new-storefront/reference.d.ts +189 -0
- package/dist/new-storefront/reference.d.ts.map +1 -0
- package/dist/new-storefront/reference.js +430 -0
- package/dist/new-storefront/reference.js.map +1 -0
- package/dist/new-storefront/rewrite.d.ts +171 -0
- package/dist/new-storefront/rewrite.d.ts.map +1 -0
- package/dist/new-storefront/rewrite.js +701 -0
- package/dist/new-storefront/rewrite.js.map +1 -0
- package/dist/release-index.json +284 -0
- package/dist/rules/action-route-permissions.d.ts +141 -0
- package/dist/rules/action-route-permissions.d.ts.map +1 -0
- package/dist/rules/action-route-permissions.js +556 -0
- package/dist/rules/action-route-permissions.js.map +1 -0
- package/dist/rules/bundle-pairing.d.ts +74 -0
- package/dist/rules/bundle-pairing.d.ts.map +1 -0
- package/dist/rules/bundle-pairing.js +281 -0
- package/dist/rules/bundle-pairing.js.map +1 -0
- package/dist/rules/channel-resolution.d.ts +17 -0
- package/dist/rules/channel-resolution.d.ts.map +1 -0
- package/dist/rules/channel-resolution.js +382 -0
- package/dist/rules/channel-resolution.js.map +1 -0
- package/dist/rules/command-coverage.d.ts +210 -0
- package/dist/rules/command-coverage.d.ts.map +1 -0
- package/dist/rules/command-coverage.js +714 -0
- package/dist/rules/command-coverage.js.map +1 -0
- package/dist/rules/container-imports.d.ts +60 -0
- package/dist/rules/container-imports.d.ts.map +1 -0
- package/dist/rules/container-imports.js +158 -0
- package/dist/rules/container-imports.js.map +1 -0
- package/dist/rules/default-language-prose.d.ts +212 -0
- package/dist/rules/default-language-prose.d.ts.map +1 -0
- package/dist/rules/default-language-prose.js +710 -0
- package/dist/rules/default-language-prose.js.map +1 -0
- package/dist/rules/diacritic-folds.d.ts +238 -0
- package/dist/rules/diacritic-folds.d.ts.map +1 -0
- package/dist/rules/diacritic-folds.js +681 -0
- package/dist/rules/diacritic-folds.js.map +1 -0
- package/dist/rules/entity-tenant-classification.d.ts +171 -0
- package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
- package/dist/rules/entity-tenant-classification.js +323 -0
- package/dist/rules/entity-tenant-classification.js.map +1 -0
- package/dist/rules/entry-presence.d.ts +142 -0
- package/dist/rules/entry-presence.d.ts.map +1 -0
- package/dist/rules/entry-presence.js +339 -0
- package/dist/rules/entry-presence.js.map +1 -0
- package/dist/rules/entry-scope.d.ts +91 -0
- package/dist/rules/entry-scope.d.ts.map +1 -0
- package/dist/rules/entry-scope.js +404 -0
- package/dist/rules/entry-scope.js.map +1 -0
- package/dist/rules/env-inputs.d.ts +222 -0
- package/dist/rules/env-inputs.d.ts.map +1 -0
- package/dist/rules/env-inputs.js +951 -0
- package/dist/rules/env-inputs.js.map +1 -0
- package/dist/rules/kernel-boundary.d.ts +37 -0
- package/dist/rules/kernel-boundary.d.ts.map +1 -0
- package/dist/rules/kernel-boundary.js +195 -0
- package/dist/rules/kernel-boundary.js.map +1 -0
- package/dist/rules/nul-bytes.d.ts +233 -0
- package/dist/rules/nul-bytes.d.ts.map +1 -0
- package/dist/rules/nul-bytes.js +332 -0
- package/dist/rules/nul-bytes.js.map +1 -0
- package/dist/rules/platform-surface.d.ts +479 -0
- package/dist/rules/platform-surface.d.ts.map +1 -0
- package/dist/rules/platform-surface.js +749 -0
- package/dist/rules/platform-surface.js.map +1 -0
- package/dist/rules/port-catches.d.ts +225 -0
- package/dist/rules/port-catches.d.ts.map +1 -0
- package/dist/rules/port-catches.js +1374 -0
- package/dist/rules/port-catches.js.map +1 -0
- package/dist/rules/port-shape.d.ts +213 -0
- package/dist/rules/port-shape.d.ts.map +1 -0
- package/dist/rules/port-shape.js +670 -0
- package/dist/rules/port-shape.js.map +1 -0
- package/dist/rules/queue-names.d.ts +108 -0
- package/dist/rules/queue-names.d.ts.map +1 -0
- package/dist/rules/queue-names.js +395 -0
- package/dist/rules/queue-names.js.map +1 -0
- package/dist/rules/singleton-identity.d.ts +205 -0
- package/dist/rules/singleton-identity.d.ts.map +1 -0
- package/dist/rules/singleton-identity.js +830 -0
- package/dist/rules/singleton-identity.js.map +1 -0
- package/dist/rules/subscribe-seam.d.ts +121 -0
- package/dist/rules/subscribe-seam.d.ts.map +1 -0
- package/dist/rules/subscribe-seam.js +594 -0
- package/dist/rules/subscribe-seam.js.map +1 -0
- package/dist/rules/transaction-context.d.ts +40 -0
- package/dist/rules/transaction-context.d.ts.map +1 -0
- package/dist/rules/transaction-context.js +294 -0
- package/dist/rules/transaction-context.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The example deployment files an instance is scaffolded with
|
|
3
|
+
* (`specs/122-layer-deployment-independence/contracts/layer-independence.md` §3,
|
|
4
|
+
* under owner ruling **D-230**).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this file exists
|
|
7
|
+
*
|
|
8
|
+
* `instance-repository.md` R2.1 lists a compose file, an nginx configuration
|
|
9
|
+
* and an `.env` as *"always in the instance"*. Measured on 2026-09-13, an
|
|
10
|
+
* instance was handed **none of them**, and no Dockerfile either — so a client
|
|
11
|
+
* asked for the owner's three-host topology wrote three deployment files from
|
|
12
|
+
* scratch, against a compose example that lives in *our* repository and assumes
|
|
13
|
+
* one host.
|
|
14
|
+
*
|
|
15
|
+
* ## They are examples, and they are derived from two axes and nothing else
|
|
16
|
+
*
|
|
17
|
+
* D-215: an instance holds *examples*, never a client's concrete files. So no
|
|
18
|
+
* real registry, no real domain, no real secret and none of this repository's
|
|
19
|
+
* container names appear below. What decides their content is the **member
|
|
20
|
+
* set** — an admin-less instance has no admin service, no admin image and no
|
|
21
|
+
* admin origin in its nginx example — and the **topology**, which selects which
|
|
22
|
+
* files are written and records nothing (R3.2). A topology written into the
|
|
23
|
+
* root manifest would be a third home for what an instance is, beside the
|
|
24
|
+
* `dependencies` that are the module set.
|
|
25
|
+
*
|
|
26
|
+
* ## The three-host split is derived, not written
|
|
27
|
+
*
|
|
28
|
+
* Every service carries the host it belongs to, and {@link composeDocument}
|
|
29
|
+
* drops an edge whose target is not in the same file. That is what makes R3.6
|
|
30
|
+
* checkable rather than asserted: the single-host graph partitions with exactly
|
|
31
|
+
* one edge crossing — `storefront -> backend`, `service_healthy`, a readiness
|
|
32
|
+
* convenience — and every `service_completed_successfully` edge in the file,
|
|
33
|
+
* `backend-install -> backend-migrate` and `backend -> backend-install`, lives
|
|
34
|
+
* entirely inside the backend host. The lost edge is replaced by **nothing**: each layer starts, answers its own
|
|
35
|
+
* health check and tolerates an absent peer, and a wait-for-it script is the
|
|
36
|
+
* temptation this rule exists to refuse. An edge added across a boundary
|
|
37
|
+
* changes what the renderer drops and reds `test/new-instance/deploy-examples.test.ts`.
|
|
38
|
+
*
|
|
39
|
+
* ## Nothing here spells a build input twice
|
|
40
|
+
*
|
|
41
|
+
* Every `--build-arg` the Dockerfile examples pass, and every `ARG` they
|
|
42
|
+
* declare, is emitted from `../lib/instance-build-inputs.js` (§2 R2.3). A
|
|
43
|
+
* fourth spelling of a build input cannot arrive here, because there is no
|
|
44
|
+
* place to write one.
|
|
45
|
+
*
|
|
46
|
+
* ## The development compose is the same catalogue in a second mode
|
|
47
|
+
*
|
|
48
|
+
* `specs/125-first-mile-install/spec.md` §4.1 (FR-100…FR-112). A scaffolded
|
|
49
|
+
* instance also carries `compose.dev.yml`, at its **root** and not under
|
|
50
|
+
* `deploy/`, and that file is **runnable as written** where every file under
|
|
51
|
+
* `deploy/` is an example the client has to build and push images for first. It
|
|
52
|
+
* is rendered by {@link developmentComposeFile} from the **same**
|
|
53
|
+
* {@link services} records, in `development` mode, and FR-103 is what that mode
|
|
54
|
+
* exists for: *"no second statement of what Endora needs to run may enter the
|
|
55
|
+
* tree"*. Two things differ between the modes and both are derived — where a
|
|
56
|
+
* value comes from (an operator-filled `${NAME}` against an inline-defaulted
|
|
57
|
+
* `${NAME:-…}`) and whether the service publishes a host port. The image, the
|
|
58
|
+
* healthcheck and the volume are one record's.
|
|
59
|
+
*
|
|
60
|
+
* Defect F-2 is what that requirement is measured against: this repository's own
|
|
61
|
+
* `docker-compose.yml` and the catalogue below name the same three stateful
|
|
62
|
+
* images, and they agreed by coincidence until
|
|
63
|
+
* `test/new-instance/dev-compose.test.ts` made them agree by instrument — that
|
|
64
|
+
* case reads both files and names both paths, and it is also why no image tag
|
|
65
|
+
* is written twice in this file, not even in this sentence. Writing a **third**
|
|
66
|
+
* statement of them into every client's tree is what
|
|
67
|
+
* this file would have done if the development compose had been authored rather
|
|
68
|
+
* than derived.
|
|
69
|
+
*/
|
|
70
|
+
import { scopeToMembers } from '@endora-commerce/contracts';
|
|
71
|
+
import { buildArgFlags, buildInputsFor, } from '../lib/instance-build-inputs.js';
|
|
72
|
+
import { InstanceInputError } from './host.js';
|
|
73
|
+
/**
|
|
74
|
+
* The vocabulary, in the order the flag's refusal prints it.
|
|
75
|
+
*
|
|
76
|
+
* `single-host` is first because it is the default, on the owner's own *"the
|
|
77
|
+
* most common scenario is probably all three layers on one machine"* (D-215).
|
|
78
|
+
*/
|
|
79
|
+
export const TOPOLOGIES = ['single-host', 'three-host'];
|
|
80
|
+
/** What a run that named no topology gets (D-230). */
|
|
81
|
+
export const DEFAULT_TOPOLOGY = 'single-host';
|
|
82
|
+
/**
|
|
83
|
+
* `--topology`'s value, or an operator-fixable refusal naming the vocabulary.
|
|
84
|
+
*
|
|
85
|
+
* F3's class: a flag the operator wrote and can rewrite, which
|
|
86
|
+
* `instance-tree.md` §4 puts at exit `1`. It is refused rather than defaulted —
|
|
87
|
+
* a run that silently ignored `--topology three-hosts` would write a
|
|
88
|
+
* single-host example to a client who asked for three, and the first they would
|
|
89
|
+
* hear of it is a compose file with a database in it.
|
|
90
|
+
*/
|
|
91
|
+
export function assertTopology(value) {
|
|
92
|
+
if (TOPOLOGIES.includes(value))
|
|
93
|
+
return value;
|
|
94
|
+
throw new InstanceInputError('F3', `\`--topology ${value}\` is not a topology this command writes an example for. It is ` +
|
|
95
|
+
`one of ${TOPOLOGIES.join(' | ')}, and it selects which example deployment files go ` +
|
|
96
|
+
`into \`deploy/\` — it is written into no manifest and read back by nothing, so a ` +
|
|
97
|
+
`machine layout stays a fact about your machines. Nothing is written.`);
|
|
98
|
+
}
|
|
99
|
+
const IMAGE = (member) => ` image: \${REGISTRY_IMAGE}/${member}:\${IMAGE_TAG:-latest}`;
|
|
100
|
+
/**
|
|
101
|
+
* The backend's environment, declared once and read by both backend services.
|
|
102
|
+
*
|
|
103
|
+
* `ADMIN_BASE_URL` and `CORS_ALLOWED_ORIGINS` are here whatever the member set
|
|
104
|
+
* is, and that is R3.7 rather than an oversight: both are read by the
|
|
105
|
+
* **backend** — `mfa` composes mailed links from the first and the platform's
|
|
106
|
+
* HTTP server reads the second — so a rule that dropped them with the admin
|
|
107
|
+
* member would be keyed on the variable's name, which is wrong on two of the
|
|
108
|
+
* three inputs that mention the admin. They are passed through from the `.env`
|
|
109
|
+
* rather than composed from a domain, because under three hosts the backend
|
|
110
|
+
* cannot guess an origin that is not its own.
|
|
111
|
+
*/
|
|
112
|
+
const BACKEND_ENVIRONMENT = [
|
|
113
|
+
'x-backend-env: &backend-env',
|
|
114
|
+
' NODE_ENV: production',
|
|
115
|
+
' LOG_LEVEL: ${LOG_LEVEL:-info}',
|
|
116
|
+
' PORT: "3001"',
|
|
117
|
+
' DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}',
|
|
118
|
+
' REDIS_URL: redis://redis:6379',
|
|
119
|
+
' MEILISEARCH_URL: http://meilisearch:7700',
|
|
120
|
+
' MEILISEARCH_API_KEY: ${MEILI_MASTER_KEY}',
|
|
121
|
+
' # The origin every payment-gateway callback, public product feed and',
|
|
122
|
+
' # newsletter confirmation link is built on. The backend refuses to boot in',
|
|
123
|
+
' # production when it resolves to nothing at all.',
|
|
124
|
+
' BACKEND_PUBLIC_URL: https://${API_DOMAIN}',
|
|
125
|
+
' PUBLIC_API_BASE_URL: https://${API_DOMAIN}',
|
|
126
|
+
' STOREFRONT_BASE_URL: https://${STOREFRONT_DOMAIN}',
|
|
127
|
+
' # Both of these are the BACKEND\'s inputs, whether or not this instance',
|
|
128
|
+
' # ships an admin: the first is what mailed links are composed from and the',
|
|
129
|
+
' # second is what the browser is allowed to call the API from. Get the',
|
|
130
|
+
' # second wrong and every call from the admin and the storefront fails with',
|
|
131
|
+
' # a message that names none of this.',
|
|
132
|
+
' ADMIN_BASE_URL: ${ADMIN_BASE_URL}',
|
|
133
|
+
' CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}',
|
|
134
|
+
' # The other half of the revalidation seam. It must be the SAME value the',
|
|
135
|
+
' # storefront has; unset, the backend\'s revalidator is a silent no-op and a',
|
|
136
|
+
' # catalogue change never reaches the rendered storefront.',
|
|
137
|
+
' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
|
|
138
|
+
' SESSION_COOKIE_SECRET: ${SESSION_COOKIE_SECRET}',
|
|
139
|
+
' # Signs newsletter confirmation and unsubscribe links. Unset, the backend',
|
|
140
|
+
' # signs them with the session key, and rotating that key invalidates every',
|
|
141
|
+
' # link still waiting in an inbox.',
|
|
142
|
+
' NEWSLETTER_TOKEN_SECRET: ${NEWSLETTER_TOKEN_SECRET}',
|
|
143
|
+
' SETTINGS_SECRET_ENCRYPTION_KEY: ${SETTINGS_SECRET_ENCRYPTION_KEY}',
|
|
144
|
+
' MFA_SECRET_ENCRYPTION_KEY: ${MFA_SECRET_ENCRYPTION_KEY}',
|
|
145
|
+
' ASSETS_LIBRARY_HMAC_KEY: ${ASSETS_LIBRARY_HMAC_KEY}',
|
|
146
|
+
' # Which upstream proxy may be believed about the client address. Unset, the',
|
|
147
|
+
' # backend trusts none and every request looks like it came from your proxy.',
|
|
148
|
+
' TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS}',
|
|
149
|
+
' TRUSTED_PROXY_ADDRESSES: ${TRUSTED_PROXY_ADDRESSES}',
|
|
150
|
+
' DEFAULT_SALES_CHANNEL_CODE: ${DEFAULT_SALES_CHANNEL_CODE}',
|
|
151
|
+
' SALES_CHANNEL_HOST_MAP: ${SALES_CHANNEL_HOST_MAP}',
|
|
152
|
+
' SMTP_URL: ${SMTP_URL}',
|
|
153
|
+
' SMTP_FROM: ${SMTP_FROM}',
|
|
154
|
+
];
|
|
155
|
+
/**
|
|
156
|
+
* {@link BACKEND_ENVIRONMENT}, completed from the declaration this run read.
|
|
157
|
+
*
|
|
158
|
+
* The static block is the fallback a run with no readable declaration still
|
|
159
|
+
* renders, and CLI 0.15.0's copy of it left `NEWSLETTER_TOKEN_SECRET` out — the
|
|
160
|
+
* only one of the platform's generable secrets it omitted, so every deployment
|
|
161
|
+
* signed newsletter links with the session key. A list can only be kept complete
|
|
162
|
+
* by somebody remembering to; so every **generable secret** the declaration
|
|
163
|
+
* scopes to the backend and the block does not name is appended here. Those are
|
|
164
|
+
* the inputs `new instance` writes a value for into a `.env` it generates, and a
|
|
165
|
+
* value the container is never handed is a value generated for nothing.
|
|
166
|
+
*/
|
|
167
|
+
function backendEnvironment(declared) {
|
|
168
|
+
const named = new Set(BACKEND_ENVIRONMENT.flatMap((line) => /^ {2}([A-Z][A-Z0-9_]*):/.exec(line)?.[1] ?? []));
|
|
169
|
+
const owed = scopeToMembers(declared, ['backend']).filter((input) => input.generable && input.secret && !named.has(input.name));
|
|
170
|
+
if (owed.length === 0)
|
|
171
|
+
return BACKEND_ENVIRONMENT;
|
|
172
|
+
return [
|
|
173
|
+
...BACKEND_ENVIRONMENT,
|
|
174
|
+
' # Generable secrets the platform declares that the lines above do not name.',
|
|
175
|
+
...owed.map((input) => ` ${input.name}: \${${input.name}}`),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Every service the examples can hold, with the host it belongs to (R1.4), in
|
|
180
|
+
* the rendering the caller asked for (FR-103).
|
|
181
|
+
*
|
|
182
|
+
* `value` and `published` are the **only** two things the mode decides, and
|
|
183
|
+
* every service below reads them rather than branching: a record that tested
|
|
184
|
+
* the mode itself would be two records sharing a name, which is what
|
|
185
|
+
* *"one catalogue, two renderings"* refuses.
|
|
186
|
+
*/
|
|
187
|
+
function services(input, mode = 'production') {
|
|
188
|
+
/**
|
|
189
|
+
* One value, from the mode's own source.
|
|
190
|
+
*
|
|
191
|
+
* In `production` it is a `${NAME}` the operator fills in `deploy/.env` — a
|
|
192
|
+
* default there would be this file choosing a client's database password. In
|
|
193
|
+
* `development` it is the same name carrying that default inline, which is
|
|
194
|
+
* FR-101: `docker compose -f compose.dev.yml up -d --wait` has to succeed in
|
|
195
|
+
* a tree whose `.env` has never been opened, and one un-defaulted expansion
|
|
196
|
+
* anywhere defeats that whatever the rest carry.
|
|
197
|
+
*/
|
|
198
|
+
const value = (name, development) => mode === 'production' ? `\${${name}}` : `\${${name}:-${development}}`;
|
|
199
|
+
/**
|
|
200
|
+
* The host ports a backing service publishes, in `development` only.
|
|
201
|
+
*
|
|
202
|
+
* Under `deploy/` these are not published: the application is a container in
|
|
203
|
+
* the same project and reaches them by service name. On a development machine
|
|
204
|
+
* the backend, the admin and the storefront run **natively** from the
|
|
205
|
+
* workspace the same run wrote (FR-104), so the only way they reach these is
|
|
206
|
+
* a published port. Each one is overridable, because two checkouts on one
|
|
207
|
+
* machine is the ordinary case and a fixed 5432 makes the second one fail.
|
|
208
|
+
*/
|
|
209
|
+
const published = (ports) => mode === 'production'
|
|
210
|
+
? []
|
|
211
|
+
: [
|
|
212
|
+
' ports:',
|
|
213
|
+
...ports.map(([variable, host, container]) => ` - '\${${variable}:-${String(host)}}:${String(container)}'`),
|
|
214
|
+
];
|
|
215
|
+
const all = [
|
|
216
|
+
{
|
|
217
|
+
name: 'postgres',
|
|
218
|
+
host: 'backend',
|
|
219
|
+
dependsOn: [],
|
|
220
|
+
body: [
|
|
221
|
+
' image: postgres:16-alpine',
|
|
222
|
+
' restart: unless-stopped',
|
|
223
|
+
' environment:',
|
|
224
|
+
` POSTGRES_USER: ${value('POSTGRES_USER', 'endora')}`,
|
|
225
|
+
` POSTGRES_PASSWORD: ${value('POSTGRES_PASSWORD', 'endora')}`,
|
|
226
|
+
` POSTGRES_DB: ${value('POSTGRES_DB', 'endora')}`,
|
|
227
|
+
' volumes:',
|
|
228
|
+
' - postgres-data:/var/lib/postgresql/data',
|
|
229
|
+
...published([['POSTGRES_PORT', 5432, 5432]]),
|
|
230
|
+
' healthcheck:',
|
|
231
|
+
` test: ['CMD-SHELL', 'pg_isready -U ${value('POSTGRES_USER', 'endora')} -d ${value('POSTGRES_DB', 'endora')}']`,
|
|
232
|
+
' interval: 5s',
|
|
233
|
+
' timeout: 5s',
|
|
234
|
+
' retries: 12',
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'redis',
|
|
239
|
+
host: 'backend',
|
|
240
|
+
dependsOn: [],
|
|
241
|
+
body: [
|
|
242
|
+
' image: redis:7-alpine',
|
|
243
|
+
' restart: unless-stopped',
|
|
244
|
+
" command: ['redis-server', '--appendonly', 'yes']",
|
|
245
|
+
' volumes:',
|
|
246
|
+
' - redis-data:/data',
|
|
247
|
+
...published([['REDIS_PORT', 6379, 6379]]),
|
|
248
|
+
' healthcheck:',
|
|
249
|
+
" test: ['CMD', 'redis-cli', 'ping']",
|
|
250
|
+
' interval: 5s',
|
|
251
|
+
' timeout: 3s',
|
|
252
|
+
' retries: 12',
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: 'meilisearch',
|
|
257
|
+
host: 'backend',
|
|
258
|
+
dependsOn: [],
|
|
259
|
+
body: [
|
|
260
|
+
' image: getmeili/meilisearch:v1.11',
|
|
261
|
+
' restart: unless-stopped',
|
|
262
|
+
' environment:',
|
|
263
|
+
// The development default is a real key rather than a blank, and it has
|
|
264
|
+
// to be: `MEILI_ENV: production` is the same record's line in both
|
|
265
|
+
// modes, and that image refuses to start on a master key shorter than
|
|
266
|
+
// 16 bytes. A development machine with no search engine is a health
|
|
267
|
+
// route reporting the instance degraded, which is the state FR-100 is
|
|
268
|
+
// about.
|
|
269
|
+
` MEILI_MASTER_KEY: ${value('MEILI_MASTER_KEY', 'endora-development-master-key')}`,
|
|
270
|
+
' MEILI_ENV: production',
|
|
271
|
+
" MEILI_NO_ANALYTICS: 'true'",
|
|
272
|
+
' volumes:',
|
|
273
|
+
' - meilisearch-data:/meili_data',
|
|
274
|
+
...published([['MEILISEARCH_PORT', 7700, 7700]]),
|
|
275
|
+
' healthcheck:',
|
|
276
|
+
' # 127.0.0.1 and not localhost: the image resolves localhost to ::1',
|
|
277
|
+
' # as well and meilisearch binds IPv4 only, so busybox wget gives up',
|
|
278
|
+
' # on the first refusal and the check never reports healthy.',
|
|
279
|
+
" test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:7700/health']",
|
|
280
|
+
' interval: 5s',
|
|
281
|
+
' timeout: 3s',
|
|
282
|
+
' retries: 12',
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
// The mail catcher exists in the development rendering only, and that is
|
|
286
|
+
// the honest shape rather than an omission: production mail goes to a real
|
|
287
|
+
// SMTP relay, and a catcher there would swallow every order confirmation a
|
|
288
|
+
// client's customers are waiting for (FR-112). `axllent/mailpit` and not
|
|
289
|
+
// `mailhog/MailHog` — measured 2026-09-14 through the GitHub API, MailHog's
|
|
290
|
+
// last commit is 2024-02-13 and Mailpit's is 2026-09-06.
|
|
291
|
+
...(mode === 'development'
|
|
292
|
+
? [
|
|
293
|
+
{
|
|
294
|
+
name: 'mailpit',
|
|
295
|
+
host: 'backend',
|
|
296
|
+
dependsOn: [],
|
|
297
|
+
body: [
|
|
298
|
+
' image: axllent/mailpit:v1.31',
|
|
299
|
+
' restart: unless-stopped',
|
|
300
|
+
' environment:',
|
|
301
|
+
// A development SMTP_URL that carries credentials is the ordinary
|
|
302
|
+
// case — the client is pointing the same configuration at a real
|
|
303
|
+
// relay tomorrow — and this catcher accepts them rather than
|
|
304
|
+
// refusing mail nobody will read anyway. It keeps no volume: a
|
|
305
|
+
// caught message is worth exactly one session.
|
|
306
|
+
" MP_SMTP_AUTH_ACCEPT_ANY: '1'",
|
|
307
|
+
" MP_SMTP_AUTH_ALLOW_INSECURE: '1'",
|
|
308
|
+
...published([
|
|
309
|
+
['MAILPIT_SMTP_PORT', 1025, 1025],
|
|
310
|
+
['MAILPIT_UI_PORT', 8025, 8025],
|
|
311
|
+
]),
|
|
312
|
+
' healthcheck:',
|
|
313
|
+
' # The same 127.0.0.1 rule the search engine\'s check carries, for',
|
|
314
|
+
' # the same reason: busybox wget gives up on the first refusal.',
|
|
315
|
+
" test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:8025/readyz']",
|
|
316
|
+
' interval: 5s',
|
|
317
|
+
' timeout: 3s',
|
|
318
|
+
' retries: 12',
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
]
|
|
322
|
+
: []),
|
|
323
|
+
{
|
|
324
|
+
name: 'backend-migrate',
|
|
325
|
+
host: 'backend',
|
|
326
|
+
dependsOn: [['postgres', 'service_healthy']],
|
|
327
|
+
body: [
|
|
328
|
+
' # One-shot: apply the migrations before the API starts, from the SAME',
|
|
329
|
+
' # image the API is about to run. A migrate job running a different',
|
|
330
|
+
' # build of the application is the half-built state that ordering',
|
|
331
|
+
' # exists to close.',
|
|
332
|
+
IMAGE('backend'),
|
|
333
|
+
' # `dist/migrate.js` is what the backend this instance was scaffolded',
|
|
334
|
+
' # with compiles `src/migrate.ts` to.',
|
|
335
|
+
" command: ['node', 'dist/migrate.js']",
|
|
336
|
+
' environment: *backend-env',
|
|
337
|
+
" restart: 'no'",
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
name: 'backend-install',
|
|
342
|
+
host: 'backend',
|
|
343
|
+
dependsOn: [
|
|
344
|
+
['postgres', 'service_healthy'],
|
|
345
|
+
['redis', 'service_healthy'],
|
|
346
|
+
['meilisearch', 'service_healthy'],
|
|
347
|
+
['backend-migrate', 'service_completed_successfully'],
|
|
348
|
+
],
|
|
349
|
+
body: [
|
|
350
|
+
' # One-shot: run every installed module\'s install hooks once the schema',
|
|
351
|
+
' # exists and before the API starts — `pnpm run module:install --all`,',
|
|
352
|
+
' # compiled. A database whose first act after the migrations is a boot',
|
|
353
|
+
' # never runs them. Idempotent: an installed module reports itself done,',
|
|
354
|
+
' # so it runs on every start, like the migrations.',
|
|
355
|
+
IMAGE('backend'),
|
|
356
|
+
" command: ['node', 'dist/module-commands/install.js', '--all']",
|
|
357
|
+
' environment: *backend-env',
|
|
358
|
+
" restart: 'no'",
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: 'backend',
|
|
363
|
+
host: 'backend',
|
|
364
|
+
dependsOn: [
|
|
365
|
+
['postgres', 'service_healthy'],
|
|
366
|
+
['redis', 'service_healthy'],
|
|
367
|
+
['meilisearch', 'service_healthy'],
|
|
368
|
+
['backend-install', 'service_completed_successfully'],
|
|
369
|
+
],
|
|
370
|
+
body: [
|
|
371
|
+
IMAGE('backend'),
|
|
372
|
+
' restart: unless-stopped',
|
|
373
|
+
' environment: *backend-env',
|
|
374
|
+
' # Published on loopback only — your own nginx proxies the public',
|
|
375
|
+
' # name here and terminates TLS. See nginx.example.conf.',
|
|
376
|
+
' ports:',
|
|
377
|
+
" - '${API_HOST_PORT}:3001'",
|
|
378
|
+
' volumes:',
|
|
379
|
+
' # The local-filesystem assets adapter writes under backend/var.',
|
|
380
|
+
' - backend-assets:/app/backend/var',
|
|
381
|
+
' healthcheck:',
|
|
382
|
+
" test: ['CMD', 'node', '-e', \"fetch('http://127.0.0.1:3001/api/v1/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]",
|
|
383
|
+
' interval: 10s',
|
|
384
|
+
' timeout: 5s',
|
|
385
|
+
' retries: 12',
|
|
386
|
+
' start_period: 30s',
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'storefront',
|
|
391
|
+
host: 'storefront',
|
|
392
|
+
// A readiness convenience and not a correctness guarantee: the storefront
|
|
393
|
+
// starts without the backend and its first page fetch fails. That is why
|
|
394
|
+
// the three-host example replaces it with nothing (R3.6).
|
|
395
|
+
dependsOn: [['backend', 'service_healthy']],
|
|
396
|
+
body: [
|
|
397
|
+
' # Built from the storefront repository `endora new storefront` wrote,',
|
|
398
|
+
' # which is its own tree and declares no module package (D-195).',
|
|
399
|
+
IMAGE('storefront'),
|
|
400
|
+
' restart: unless-stopped',
|
|
401
|
+
' environment:',
|
|
402
|
+
' NODE_ENV: production',
|
|
403
|
+
" PORT: '3000'",
|
|
404
|
+
' HOSTNAME: 0.0.0.0',
|
|
405
|
+
...storefrontBackendUrl(input.topology),
|
|
406
|
+
...revalidateSecret(input.topology),
|
|
407
|
+
' #',
|
|
408
|
+
' # The public origin is NOT settable here. The origin this shop puts',
|
|
409
|
+
' # in every canonical link, in its sitemap and in its robots.txt is',
|
|
410
|
+
' # inlined by `next build` from NEXT_PUBLIC_SITE_URL, and the repair',
|
|
411
|
+
' # for a wrong one is a rebuild rather than a value in this block.',
|
|
412
|
+
' ports:',
|
|
413
|
+
" - '${STOREFRONT_HOST_PORT}:3000'",
|
|
414
|
+
' healthcheck:',
|
|
415
|
+
" test: ['CMD', 'node', '-e', \"fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"]",
|
|
416
|
+
' interval: 10s',
|
|
417
|
+
' timeout: 5s',
|
|
418
|
+
' retries: 12',
|
|
419
|
+
' start_period: 20s',
|
|
420
|
+
],
|
|
421
|
+
},
|
|
422
|
+
];
|
|
423
|
+
if (input.admin) {
|
|
424
|
+
all.push({
|
|
425
|
+
name: 'admin',
|
|
426
|
+
host: 'admin',
|
|
427
|
+
// Nothing. The admin is static files behind a web server and talks to the
|
|
428
|
+
// API from the browser, so it has no peer to wait for — which is why a
|
|
429
|
+
// host of its own costs it nothing at all.
|
|
430
|
+
dependsOn: [],
|
|
431
|
+
body: [
|
|
432
|
+
' # Static files behind nginx. No database, no cache, no search, and no',
|
|
433
|
+
' # `depends_on`: it reaches the API from the browser, so it starts,',
|
|
434
|
+
' # restarts and rolls back on a schedule of its own.',
|
|
435
|
+
IMAGE('admin'),
|
|
436
|
+
' restart: unless-stopped',
|
|
437
|
+
' ports:',
|
|
438
|
+
" - '${ADMIN_HOST_PORT}:80'",
|
|
439
|
+
' healthcheck:',
|
|
440
|
+
" test: ['CMD', 'wget', '--quiet', '--spider', 'http://127.0.0.1:80/']",
|
|
441
|
+
' interval: 10s',
|
|
442
|
+
' timeout: 3s',
|
|
443
|
+
' retries: 6',
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return all;
|
|
448
|
+
}
|
|
449
|
+
/** R3.4 — a cross-host URL is a real origin, never a container name. */
|
|
450
|
+
function storefrontBackendUrl(topology) {
|
|
451
|
+
if (topology === 'single-host') {
|
|
452
|
+
return [
|
|
453
|
+
' # The server-side fetcher, over this project\'s own network. The',
|
|
454
|
+
' # container name resolves because both services are in one project;',
|
|
455
|
+
' # split them across hosts and this becomes a public origin.',
|
|
456
|
+
' BACKEND_BASE_URL: http://backend:3001',
|
|
457
|
+
];
|
|
458
|
+
}
|
|
459
|
+
return [
|
|
460
|
+
' # The backend is on another machine, so this is its PUBLIC origin. A',
|
|
461
|
+
' # container name resolves only on a shared Docker network and would',
|
|
462
|
+
' # fail here with a name lookup that says nothing about why.',
|
|
463
|
+
' # Where you have a private link between the two hosts, put that',
|
|
464
|
+
' # address here instead — it is the same value, over a cheaper path.',
|
|
465
|
+
' #',
|
|
466
|
+
' # WHAT THE SPLIT COSTS, AND IT IS NOT IN THE DIFF: every',
|
|
467
|
+
' # server-rendered page now makes a network round trip to a different',
|
|
468
|
+
' # machine. On one host that fetch was a bridge hop; here it is a TLS',
|
|
469
|
+
' # request over whatever is between the two, on every render.',
|
|
470
|
+
' BACKEND_BASE_URL: https://${API_DOMAIN}',
|
|
471
|
+
];
|
|
472
|
+
}
|
|
473
|
+
/** R3.5's second cost, stated in the file the operator edits. */
|
|
474
|
+
function revalidateSecret(topology) {
|
|
475
|
+
if (topology === 'single-host') {
|
|
476
|
+
return [
|
|
477
|
+
' # The secret `/api/revalidate` compares the backend\'s header',
|
|
478
|
+
' # against. Unset here, the endpoint answers 401 to a backend that is',
|
|
479
|
+
' # configured correctly.',
|
|
480
|
+
' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
return [
|
|
484
|
+
' # The secret `/api/revalidate` compares the backend\'s header against,',
|
|
485
|
+
' # and it must be the SAME value the backend has.',
|
|
486
|
+
' #',
|
|
487
|
+
' # THE SECOND COST OF THE SPLIT: on one host this value never left a',
|
|
488
|
+
' # private bridge network. Here it is a bearer secret on a public',
|
|
489
|
+
' # endpoint, presented over the internet on every catalogue change.',
|
|
490
|
+
' # Generate it freshly per environment and rotate it like a password.',
|
|
491
|
+
' REVALIDATE_SECRET: ${REVALIDATE_SECRET}',
|
|
492
|
+
];
|
|
493
|
+
}
|
|
494
|
+
/** The named volumes a set of services needs, derived from what they mount. */
|
|
495
|
+
function volumesFor(chosen) {
|
|
496
|
+
const named = new Set();
|
|
497
|
+
for (const service of chosen) {
|
|
498
|
+
for (const line of service.body) {
|
|
499
|
+
const match = /^ {6}- ([a-z-]+):\//.exec(line);
|
|
500
|
+
if (match !== null)
|
|
501
|
+
named.add(match[1]);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return [...named].sort();
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* One compose document over a set of services.
|
|
508
|
+
*
|
|
509
|
+
* An edge whose target is not in the same document is **dropped**, never
|
|
510
|
+
* translated: that is R3.6, and it is what makes the three-host files a
|
|
511
|
+
* derivation of the single-host one rather than a second authoring of it.
|
|
512
|
+
*/
|
|
513
|
+
function composeDocument(header, chosen, declared) {
|
|
514
|
+
const present = new Set(chosen.map((service) => service.name));
|
|
515
|
+
const lines = [...header, ''];
|
|
516
|
+
if (chosen.some((service) => service.name === 'backend')) {
|
|
517
|
+
lines.push(...backendEnvironment(declared), '');
|
|
518
|
+
}
|
|
519
|
+
lines.push('services:');
|
|
520
|
+
for (const service of chosen) {
|
|
521
|
+
lines.push(` ${service.name}:`, ...service.body);
|
|
522
|
+
const edges = service.dependsOn.filter(([target]) => present.has(target));
|
|
523
|
+
if (edges.length > 0) {
|
|
524
|
+
lines.push(' depends_on:');
|
|
525
|
+
for (const [target, condition] of edges) {
|
|
526
|
+
lines.push(` ${target}:`, ` condition: ${condition}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
lines.push('');
|
|
530
|
+
}
|
|
531
|
+
const volumes = volumesFor(chosen);
|
|
532
|
+
if (volumes.length > 0) {
|
|
533
|
+
lines.push('volumes:', ...volumes.map((name) => ` ${name}:`), '');
|
|
534
|
+
}
|
|
535
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
536
|
+
}
|
|
537
|
+
// ── the development compose (`specs/125-first-mile-install/` §4.1) ──────────
|
|
538
|
+
/** Where the development compose is written, relative to the instance root. */
|
|
539
|
+
export const DEV_COMPOSE_PATH = 'compose.dev.yml';
|
|
540
|
+
/**
|
|
541
|
+
* The names a document expands **without** an inline default (FR-111).
|
|
542
|
+
*
|
|
543
|
+
* The guard `envExampleFor` does not give you, and the reason it is a second
|
|
544
|
+
* pattern rather than that function's constant is one character:
|
|
545
|
+
* `/\$\{([A-Z0-9_]+)(?::-[^}]*)?\}/g`'s default clause is **non-capturing**, so
|
|
546
|
+
* a match there says nothing about which of the two forms it was. Reusing it
|
|
547
|
+
* here would report every expansion as defaulted — a guard that is green on
|
|
548
|
+
* precisely the document it was written to refuse (spec §5.3.3).
|
|
549
|
+
*
|
|
550
|
+
* Sorted and de-duplicated, because the sentence this feeds is read by whoever
|
|
551
|
+
* added the record, and three occurrences of one name is one repair.
|
|
552
|
+
*/
|
|
553
|
+
export function undefaultedExpansions(document) {
|
|
554
|
+
const names = new Set();
|
|
555
|
+
for (const match of document.matchAll(/\$\{([A-Z0-9_]+)(:-[^}]*)?\}/g)) {
|
|
556
|
+
if (match[2] === undefined)
|
|
557
|
+
names.add(match[1]);
|
|
558
|
+
}
|
|
559
|
+
return [...names].sort();
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The header of the one file in a scaffolded tree that is meant to be **run**.
|
|
563
|
+
*
|
|
564
|
+
* It says the command, it says what the file is not, and it says where the
|
|
565
|
+
* other compose files are — because a tree holding both a production example
|
|
566
|
+
* and a development stack is a tree in which somebody will start the wrong one.
|
|
567
|
+
*/
|
|
568
|
+
const DEVELOPMENT_HEADER = [
|
|
569
|
+
'# The backing services this instance needs on a DEVELOPMENT machine.',
|
|
570
|
+
'#',
|
|
571
|
+
'# docker compose -f compose.dev.yml up -d --wait',
|
|
572
|
+
'# docker compose -f compose.dev.yml down',
|
|
573
|
+
'#',
|
|
574
|
+
'# `--wait` blocks until every health check below passes, which is what stops',
|
|
575
|
+
'# `pnpm run migrate` racing a Postgres that is still initialising. Both lines',
|
|
576
|
+
'# are `pnpm run dev:services` and `pnpm run dev:services:down` in this',
|
|
577
|
+
'# repository, and this file is what they run.',
|
|
578
|
+
'#',
|
|
579
|
+
'# EVERY value below carries an inline default, so this works in a tree whose',
|
|
580
|
+
'# `.env` you have never opened. Set any of them in `.env` beside this file to',
|
|
581
|
+
'# override one — two checkouts on one machine want different host ports.',
|
|
582
|
+
'#',
|
|
583
|
+
'# IT IS NOT A DEPLOYMENT, and it is deliberately not at one of Compose\'s four',
|
|
584
|
+
'# default filenames: a bare `docker compose up` in this tree finds nothing.',
|
|
585
|
+
'# The examples for a machine you own are in `deploy/` — they pull images you',
|
|
586
|
+
'# have built and pushed, and they publish nothing on loopback by accident.',
|
|
587
|
+
'#',
|
|
588
|
+
'# There is no application service here. The backend, the admin and the',
|
|
589
|
+
'# storefront run natively from this workspace (`pnpm run start`,',
|
|
590
|
+
'# `pnpm run preview:admin`), against the ports published below.',
|
|
591
|
+
];
|
|
592
|
+
/**
|
|
593
|
+
* `compose.dev.yml`, rendered from the catalogue in `development` mode.
|
|
594
|
+
*
|
|
595
|
+
* **Which services reach it is derived rather than listed** (FR-104): every
|
|
596
|
+
* service whose image is this repository's own build — `${REGISTRY_IMAGE}/…` —
|
|
597
|
+
* is an application service and is dropped, and everything else is a backing
|
|
598
|
+
* service and is kept. So a backing service the catalogue gains appears here in
|
|
599
|
+
* the same merge request with nothing edited, and an application service it
|
|
600
|
+
* gains stays out, which is the direction both rules want. A list would have
|
|
601
|
+
* been a second statement of the partition.
|
|
602
|
+
*
|
|
603
|
+
* `extra` exists for the guard's own proof and for nothing else: it is the only
|
|
604
|
+
* way to put an expansion into this document that the catalogue cannot produce,
|
|
605
|
+
* and a test that could not do that would be asserting the throw over a
|
|
606
|
+
* document that never reaches it.
|
|
607
|
+
*/
|
|
608
|
+
export function developmentComposeFile(input, extra = []) {
|
|
609
|
+
const backing = services(input, 'development').filter((service) => !service.body.some((line) => line.includes('${REGISTRY_IMAGE}')));
|
|
610
|
+
const content = composeDocument([...DEVELOPMENT_HEADER, ...extra], backing, input.declared);
|
|
611
|
+
const blank = undefaultedExpansions(content);
|
|
612
|
+
if (blank.length > 0) {
|
|
613
|
+
throw new Error(`deploy: ${DEV_COMPOSE_PATH} expands ${blank.join(', ')} with no inline default. This ` +
|
|
614
|
+
'file is the one a client starts without editing anything, so an expansion with no ' +
|
|
615
|
+
'`:-` default is a container that comes up wrong — or not at all — in a tree whose ' +
|
|
616
|
+
'`.env` has never been opened. Give the record a default, or keep the value out of ' +
|
|
617
|
+
'the development rendering.');
|
|
618
|
+
}
|
|
619
|
+
return { path: DEV_COMPOSE_PATH, kind: 'derived', member: 'root', content };
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* The defaults a rendered document carries, by name.
|
|
623
|
+
*
|
|
624
|
+
* Read off the document rather than off the catalogue that wrote it, which is
|
|
625
|
+
* the same discipline `envExampleFor` states for the production examples:
|
|
626
|
+
* *"derived from the rendered document rather than listed, so the two cannot
|
|
627
|
+
* come apart"*. A caller therefore cannot be handed a port the file does not
|
|
628
|
+
* publish.
|
|
629
|
+
*/
|
|
630
|
+
function inlineDefaults(document) {
|
|
631
|
+
const defaults = new Map();
|
|
632
|
+
for (const match of document.matchAll(/\$\{([A-Z0-9_]+):-([^}]*)\}/g)) {
|
|
633
|
+
if (!defaults.has(match[1]))
|
|
634
|
+
defaults.set(match[1], match[2]);
|
|
635
|
+
}
|
|
636
|
+
return defaults;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* How a **natively running** process reaches the development stack (FR-105).
|
|
640
|
+
*
|
|
641
|
+
* One entry per input the rendered document actually answers, composed from the
|
|
642
|
+
* defaults that document carries — so a changed port or a changed credential
|
|
643
|
+
* moves the address in the same run, with no second list to edit. An entry
|
|
644
|
+
* whose composition names something the document does not carry is **not
|
|
645
|
+
* produced**: a document with no mail catcher yields no `SMTP_URL`, and nothing
|
|
646
|
+
* here has to know that `SMTP_URL` is the `email` module's input.
|
|
647
|
+
*
|
|
648
|
+
* That is the compose half of FR-105's intersection. The other half is the
|
|
649
|
+
* instance's own declaration and belongs to the caller: *"an input is derived
|
|
650
|
+
* only when the development compose provides a service for it **and** this
|
|
651
|
+
* instance declares it"*, and a caller that skipped the second test would write
|
|
652
|
+
* a value for a module nobody installed.
|
|
653
|
+
*
|
|
654
|
+
* `localhost` and not a container name, for the reason FR-104 gives: nothing in
|
|
655
|
+
* this document is an application, so every reader of these values is a process
|
|
656
|
+
* on the host reaching a published port.
|
|
657
|
+
*/
|
|
658
|
+
export function developmentAddresses(document) {
|
|
659
|
+
const defaults = inlineDefaults(document);
|
|
660
|
+
const addresses = new Map();
|
|
661
|
+
/** One input, composed only if the document answers every name it needs. */
|
|
662
|
+
const compose = (name, needs, build) => {
|
|
663
|
+
if (!needs.every((key) => defaults.has(key)))
|
|
664
|
+
return;
|
|
665
|
+
addresses.set(name, build((key) => defaults.get(key)));
|
|
666
|
+
};
|
|
667
|
+
compose('DATABASE_URL', ['POSTGRES_USER', 'POSTGRES_PASSWORD', 'POSTGRES_PORT', 'POSTGRES_DB'], (of) => `postgresql://${of('POSTGRES_USER')}:${of('POSTGRES_PASSWORD')}@localhost:${of('POSTGRES_PORT')}/${of('POSTGRES_DB')}`);
|
|
668
|
+
compose('REDIS_URL', ['REDIS_PORT'], (of) => `redis://localhost:${of('REDIS_PORT')}`);
|
|
669
|
+
compose('MEILISEARCH_URL', ['MEILISEARCH_PORT'], (of) => `http://localhost:${of('MEILISEARCH_PORT')}`);
|
|
670
|
+
compose('MEILISEARCH_API_KEY', ['MEILI_MASTER_KEY'], (of) => of('MEILI_MASTER_KEY'));
|
|
671
|
+
compose('SMTP_URL', ['MAILPIT_SMTP_PORT'], (of) => `smtp://localhost:${of('MAILPIT_SMTP_PORT')}`);
|
|
672
|
+
return addresses;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Where the mail catcher's own interface is, if this document runs one.
|
|
676
|
+
*
|
|
677
|
+
* Not an environment input — nothing reads it — and therefore not in
|
|
678
|
+
* {@link developmentAddresses}. It is printed, because a client whose instance
|
|
679
|
+
* sends mail to a port has no way to learn where that mail went.
|
|
680
|
+
*/
|
|
681
|
+
export function developmentMailUrl(document) {
|
|
682
|
+
const port = inlineDefaults(document).get('MAILPIT_UI_PORT');
|
|
683
|
+
return port === undefined ? undefined : `http://localhost:${port}`;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* What a *running* stack needs, as opposed to what a *build* needs.
|
|
687
|
+
*
|
|
688
|
+
* What this list is **for**, since G3: an **example value**. The sentence
|
|
689
|
+
* saying what a variable decides comes from the instance's own environment
|
|
690
|
+
* declaration wherever one covers the name, and an entry that carries a
|
|
691
|
+
* `meaning` is one no declaration does — an image path, a host port, the
|
|
692
|
+
* database container's own credentials. That split is the whole of it: a
|
|
693
|
+
* declaration deliberately carries no default (`environment-inputs.md` R1.3),
|
|
694
|
+
* and this file's whole subject is a stack an operator can paste and start.
|
|
695
|
+
*
|
|
696
|
+
* Every entry here is an **example**, per D-215: no real domain, no real
|
|
697
|
+
* registry and no real secret. Which of them reach a given host's
|
|
698
|
+
* `.env.example` is not decided here — {@link envExampleFor} takes the ones
|
|
699
|
+
* that host's compose file actually expands, so a variable nothing reads is
|
|
700
|
+
* never handed to an operator to fill in.
|
|
701
|
+
*/
|
|
702
|
+
const RUNTIME_INPUTS = [
|
|
703
|
+
{
|
|
704
|
+
name: 'REGISTRY_IMAGE',
|
|
705
|
+
meaning: 'The image path without the per-layer suffix. Each service appends its own name, so ' +
|
|
706
|
+
'one value serves the backend, the storefront and the admin.',
|
|
707
|
+
example: 'registry.example.com/your-group/your-project',
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
name: 'IMAGE_TAG',
|
|
711
|
+
meaning: 'Which build to run. A deploy job normally supplies the commit it built; set a value ' +
|
|
712
|
+
'here only for a manual `up`.',
|
|
713
|
+
example: 'latest',
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: 'API_DOMAIN',
|
|
717
|
+
meaning: 'The public host the backend answers on. DNS must already point at it.',
|
|
718
|
+
example: 'api.example.com',
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
name: 'STOREFRONT_DOMAIN',
|
|
722
|
+
meaning: 'The public host the storefront answers on.',
|
|
723
|
+
example: 'example.com',
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: 'ADMIN_BASE_URL',
|
|
727
|
+
meaning: "The admin's public origin, as the BACKEND needs to know it: `mfa` composes the links " +
|
|
728
|
+
'it mails from this value. It is the backend\'s input and not the admin\'s, so it is ' +
|
|
729
|
+
'here whether or not this instance ships an admin — an operator running one elsewhere ' +
|
|
730
|
+
'still owes the backend its origin.',
|
|
731
|
+
example: 'https://admin.example.com',
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: 'CORS_ALLOWED_ORIGINS',
|
|
735
|
+
meaning: 'Which browser origins may call the API. Comma-separated, scheme and host, no trailing ' +
|
|
736
|
+
'slash — the storefront and the admin. Wrong here, every call from both fails with a ' +
|
|
737
|
+
'message that names none of this.',
|
|
738
|
+
example: 'https://example.com,https://admin.example.com',
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: 'API_HOST_PORT',
|
|
742
|
+
meaning: 'Where the backend is published on this machine. Keep the 127.0.0.1 prefix: your own ' +
|
|
743
|
+
'nginx proxies the public name here, and nothing outside the machine should reach it.',
|
|
744
|
+
example: '127.0.0.1:3001',
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: 'STOREFRONT_HOST_PORT',
|
|
748
|
+
meaning: 'Where the storefront is published on this machine. Keep the 127.0.0.1 prefix, for the ' +
|
|
749
|
+
'same reason the API port has one.',
|
|
750
|
+
example: '127.0.0.1:3000',
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
name: 'ADMIN_HOST_PORT',
|
|
754
|
+
meaning: 'Where the admin is published on this machine. Keep the 127.0.0.1 prefix: your own ' +
|
|
755
|
+
'nginx proxies the public name here, and nothing outside the machine should reach it.',
|
|
756
|
+
example: '127.0.0.1:8080',
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
name: 'POSTGRES_USER',
|
|
760
|
+
meaning: 'The database role the backend connects as.',
|
|
761
|
+
example: 'endora',
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: 'POSTGRES_PASSWORD',
|
|
765
|
+
meaning: 'Its password. Generate one: `openssl rand -hex 32`.',
|
|
766
|
+
example: 'change-me-generate-one',
|
|
767
|
+
},
|
|
768
|
+
{ name: 'POSTGRES_DB', meaning: 'The database name.', example: 'endora' },
|
|
769
|
+
{
|
|
770
|
+
name: 'MEILI_MASTER_KEY',
|
|
771
|
+
meaning: 'The search engine\'s master key. `openssl rand -base64 32`.',
|
|
772
|
+
example: 'change-me-generate-one',
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
name: 'SESSION_COOKIE_SECRET',
|
|
776
|
+
meaning: 'Signs the session cookie. `openssl rand -hex 32`, freshly per environment.',
|
|
777
|
+
example: 'change-me-generate-one',
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: 'NEWSLETTER_TOKEN_SECRET',
|
|
781
|
+
meaning: 'Signs newsletter confirmation and unsubscribe links. `openssl rand -base64 32`. ' +
|
|
782
|
+
'Empty, the backend signs them with the session key instead.',
|
|
783
|
+
// Empty rather than a placeholder: empty is a working state, and a
|
|
784
|
+
// placeholder is a signing key every copy of this file shares.
|
|
785
|
+
example: '',
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: 'SETTINGS_SECRET_ENCRYPTION_KEY',
|
|
789
|
+
meaning: 'Encrypts the secret Settings modules store. Base64, 32 bytes.',
|
|
790
|
+
// Empty rather than a placeholder: `change-me-generate-one` decodes to 16
|
|
791
|
+
// bytes, which the backend boots on silently and then refuses at the first
|
|
792
|
+
// save of a secret setting. Empty at least logs a warning at boot.
|
|
793
|
+
example: '',
|
|
794
|
+
whenEmpty: 'Generate one with `openssl rand -base64 32`. Left empty, the backend boots with a ' +
|
|
795
|
+
'warning and secret settings stay unavailable until it is set.',
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
name: 'MFA_SECRET_ENCRYPTION_KEY',
|
|
799
|
+
meaning: 'Encrypts stored MFA secrets. `openssl rand -base64 32`. Empty, no second factor ' +
|
|
800
|
+
'can be enrolled.',
|
|
801
|
+
// Empty rather than a placeholder: `change-me-generate-one` decodes to 16
|
|
802
|
+
// bytes, and `mfa` refuses to boot on a key that is not 32.
|
|
803
|
+
example: '',
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
name: 'ASSETS_LIBRARY_HMAC_KEY',
|
|
807
|
+
meaning: 'Signs asset URLs. `openssl rand -hex 32`.',
|
|
808
|
+
example: 'change-me-generate-one',
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
name: 'REVALIDATE_SECRET',
|
|
812
|
+
meaning: 'The shared secret the backend presents to the storefront after a content write, and ' +
|
|
813
|
+
'the one the storefront compares it against. The SAME value on both. Empty, and the ' +
|
|
814
|
+
'revalidator is a silent no-op: a catalogue change does not appear until the fetch ' +
|
|
815
|
+
'cache expires on its own.',
|
|
816
|
+
example: 'change-me-generate-one',
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
name: 'TRUSTED_PROXY_HOPS',
|
|
820
|
+
meaning: 'How many proxies sit in front of the backend. One nginx is 1; a CDN in front of it ' +
|
|
821
|
+
'makes it 2. Unset, the backend believes no forwarded address, which collapses the ' +
|
|
822
|
+
'per-IP rate limit into one bucket for the whole internet.',
|
|
823
|
+
example: '1',
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
name: 'TRUSTED_PROXY_ADDRESSES',
|
|
827
|
+
meaning: 'The alternative to the hop count, for a proxy whose address is fixed: IPs, CIDR ' +
|
|
828
|
+
'ranges, or loopback / linklocal / uniquelocal. Set ONE of the two — the backend ' +
|
|
829
|
+
'refuses to boot with both, and there is deliberately no "trust everything" value.',
|
|
830
|
+
example: '',
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: 'DEFAULT_SALES_CHANNEL_CODE',
|
|
834
|
+
meaning: 'The channel content resolves against when the request names none.',
|
|
835
|
+
example: 'default',
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: 'SALES_CHANNEL_HOST_MAP',
|
|
839
|
+
meaning: 'host=channelCode pairs, comma-separated. Empty disables host resolution.',
|
|
840
|
+
example: '',
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
name: 'SMTP_URL',
|
|
844
|
+
meaning: 'Where mail goes. Empty falls back to a console mailer that delivers nothing.',
|
|
845
|
+
example: '',
|
|
846
|
+
},
|
|
847
|
+
{ name: 'SMTP_FROM', meaning: 'The From address on that mail.', example: 'no-reply@example.com' },
|
|
848
|
+
{ name: 'LOG_LEVEL', meaning: 'How much the backend says.', example: 'info' },
|
|
849
|
+
];
|
|
850
|
+
/**
|
|
851
|
+
* The `.env.example` for one compose file — exactly the variables it expands.
|
|
852
|
+
*
|
|
853
|
+
* Derived from the rendered document rather than listed, so the two cannot come
|
|
854
|
+
* apart: a variable the compose file expands and this file does not declare is
|
|
855
|
+
* an operator finding a blank at run time, and a variable declared here that
|
|
856
|
+
* nothing reads is a value nobody can act on. A `${NAME}` with no entry in
|
|
857
|
+
* {@link RUNTIME_INPUTS} is a programming error and says so rather than being
|
|
858
|
+
* written out with no explanation.
|
|
859
|
+
*/
|
|
860
|
+
function envExampleFor(header, compose, declared) {
|
|
861
|
+
const referenced = new Set([...compose.matchAll(/\$\{([A-Z0-9_]+)(?::-[^}]*)?\}/g)].map((match) => match[1]));
|
|
862
|
+
const describes = new Map(declared.map((input) => [input.name, input]));
|
|
863
|
+
const lines = [...header];
|
|
864
|
+
for (const input of RUNTIME_INPUTS) {
|
|
865
|
+
if (!referenced.has(input.name))
|
|
866
|
+
continue;
|
|
867
|
+
referenced.delete(input.name);
|
|
868
|
+
// The declaration wins wherever one covers the name — see
|
|
869
|
+
// {@link RuntimeInput.meaning} for why the fallback below still exists.
|
|
870
|
+
const declaration = describes.get(input.name);
|
|
871
|
+
const sentence = declaration === undefined ? input.meaning : declarationSentence(declaration);
|
|
872
|
+
lines.push('', ...wrapComment(sentence), ...(input.whenEmpty === undefined ? [] : wrapComment(input.whenEmpty)), `${input.name}=${input.example}`);
|
|
873
|
+
}
|
|
874
|
+
// A variable only the declaration covers. It is rendered rather than refused
|
|
875
|
+
// because the declaration is the more authoritative of the two sources: an
|
|
876
|
+
// input a module started reading arrives here with its own sentence and needs
|
|
877
|
+
// no entry written beside it. It gets no example, because a declaration
|
|
878
|
+
// deliberately carries no default (`environment-inputs.md` R1.3) and one
|
|
879
|
+
// invented here would be the seventieth home of a value nobody reviewed.
|
|
880
|
+
for (const input of declared) {
|
|
881
|
+
if (!referenced.has(input.name))
|
|
882
|
+
continue;
|
|
883
|
+
referenced.delete(input.name);
|
|
884
|
+
lines.push('', ...wrapComment(declarationSentence(input)), `${input.name}=`);
|
|
885
|
+
}
|
|
886
|
+
if (referenced.size > 0) {
|
|
887
|
+
throw new Error(`deploy: the compose example expands ${[...referenced].sort().join(', ')}, which ` +
|
|
888
|
+
'neither RUNTIME_INPUTS nor this instance\'s environment declaration describes. An ' +
|
|
889
|
+
'operator would be handed a stack with a blank where a value belongs and no sentence ' +
|
|
890
|
+
'saying what it decides.');
|
|
891
|
+
}
|
|
892
|
+
return `${lines.join('\n')}\n`;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* One declared input's sentence, as the operator reads it in a `.env.example`.
|
|
896
|
+
*
|
|
897
|
+
* The declaration's own words in both halves — what it decides, and what leaving
|
|
898
|
+
* it unset costs — because a rewrite here would be a second statement of the
|
|
899
|
+
* author's fact with nothing reconciling the two.
|
|
900
|
+
*/
|
|
901
|
+
function declarationSentence(input) {
|
|
902
|
+
if (input.requirement.kind === 'optional') {
|
|
903
|
+
return `${input.describes.en} Without it, ${input.requirement.without.en}`;
|
|
904
|
+
}
|
|
905
|
+
if (input.requirement.kind === 'requiredWhen') {
|
|
906
|
+
return (`${input.describes.en} Required when ` +
|
|
907
|
+
`${input.requirement.input}=${input.requirement.equals}.`);
|
|
908
|
+
}
|
|
909
|
+
return `${input.describes.en} Required.`;
|
|
910
|
+
}
|
|
911
|
+
/** One sentence, wrapped to a width a terminal shows whole. */
|
|
912
|
+
function wrapComment(text, prefix = '# ') {
|
|
913
|
+
const out = [];
|
|
914
|
+
let line = '';
|
|
915
|
+
for (const word of text.split(' ')) {
|
|
916
|
+
if (line.length > 0 && `${line} ${word}`.length > 84) {
|
|
917
|
+
out.push(`${prefix}${line}`);
|
|
918
|
+
line = word;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
line = line.length === 0 ? word : `${line} ${word}`;
|
|
922
|
+
}
|
|
923
|
+
if (line.length > 0)
|
|
924
|
+
out.push(`${prefix}${line}`);
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
// ── the nginx example ──────────────────────────────────────────────────────
|
|
928
|
+
/** One `server` block, per public origin this instance actually has. */
|
|
929
|
+
function nginxBlock(title, domain, variable, port, extra) {
|
|
930
|
+
return [
|
|
931
|
+
`# --- ${title} ${'-'.repeat(Math.max(0, 60 - title.length))}`,
|
|
932
|
+
'server {',
|
|
933
|
+
' listen 80;',
|
|
934
|
+
' listen [::]:80;',
|
|
935
|
+
` server_name ${domain};${' '.repeat(Math.max(1, 28 - domain.length))}# <- ${variable}`,
|
|
936
|
+
'',
|
|
937
|
+
...extra,
|
|
938
|
+
' location / {',
|
|
939
|
+
` proxy_pass http://${port};`,
|
|
940
|
+
' proxy_http_version 1.1;',
|
|
941
|
+
' proxy_set_header Host $host;',
|
|
942
|
+
' proxy_set_header X-Real-IP $remote_addr;',
|
|
943
|
+
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
|
|
944
|
+
' proxy_set_header X-Forwarded-Proto $scheme;',
|
|
945
|
+
' proxy_set_header X-Forwarded-Host $host;',
|
|
946
|
+
' proxy_set_header Upgrade $http_upgrade;',
|
|
947
|
+
' proxy_set_header Connection $connection_upgrade;',
|
|
948
|
+
' proxy_read_timeout 120s;',
|
|
949
|
+
' }',
|
|
950
|
+
'}',
|
|
951
|
+
'',
|
|
952
|
+
];
|
|
953
|
+
}
|
|
954
|
+
function nginxExample(input) {
|
|
955
|
+
return `${[
|
|
956
|
+
'# An EXAMPLE reverse-proxy configuration for the nginx already on this host.',
|
|
957
|
+
'#',
|
|
958
|
+
'# The compose stack publishes each layer on a loopback port; this file',
|
|
959
|
+
'# terminates TLS and proxies each public name to the matching local port. It',
|
|
960
|
+
'# is not applied by anything: copy it, put your own names in, and let certbot',
|
|
961
|
+
'# rewrite each block in place.',
|
|
962
|
+
'#',
|
|
963
|
+
'# sudo cp nginx.example.conf /etc/nginx/sites-available/endora',
|
|
964
|
+
'# sudo ln -s /etc/nginx/sites-available/endora /etc/nginx/sites-enabled/endora',
|
|
965
|
+
'# sudo nginx -t && sudo systemctl reload nginx',
|
|
966
|
+
'# sudo certbot --nginx -d example.com -d api.example.com',
|
|
967
|
+
'#',
|
|
968
|
+
'# The blocks start as plain :80 so certbot has something to attach to. If you',
|
|
969
|
+
'# changed the host ports in .env, change the proxy_pass targets to match.',
|
|
970
|
+
'',
|
|
971
|
+
'# Next.js and the API\'s event streams both need the Connection/Upgrade dance.',
|
|
972
|
+
'map $http_upgrade $connection_upgrade {',
|
|
973
|
+
' default upgrade;',
|
|
974
|
+
" '' close;",
|
|
975
|
+
'}',
|
|
976
|
+
'',
|
|
977
|
+
...nginxBlock('Storefront', 'example.com', 'STOREFRONT_DOMAIN', '127.0.0.1:3000', [
|
|
978
|
+
' # Room for the SSR responses of a large catalogue page.',
|
|
979
|
+
' client_max_body_size 25m;',
|
|
980
|
+
'',
|
|
981
|
+
]),
|
|
982
|
+
...(input.admin
|
|
983
|
+
? nginxBlock('Admin', 'admin.example.com', 'ADMIN_DOMAIN', '127.0.0.1:8080', [])
|
|
984
|
+
: [
|
|
985
|
+
'# There is deliberately no admin block: this instance was scaffolded',
|
|
986
|
+
'# without the admin member, so there is no admin artefact to proxy and no',
|
|
987
|
+
'# third public name. The backend still needs to be told an admin origin if',
|
|
988
|
+
'# you run one elsewhere — see ADMIN_BASE_URL in .env.example.',
|
|
989
|
+
'',
|
|
990
|
+
]),
|
|
991
|
+
...nginxBlock('Backend API', 'api.example.com', 'API_DOMAIN', '127.0.0.1:3001', [
|
|
992
|
+
' # Asset uploads and bulk imports go straight to the API.',
|
|
993
|
+
' client_max_body_size 100m;',
|
|
994
|
+
'',
|
|
995
|
+
]),
|
|
996
|
+
]
|
|
997
|
+
.join('\n')
|
|
998
|
+
.replace(/\n+$/, '')}\n`;
|
|
999
|
+
}
|
|
1000
|
+
// ── the image examples ─────────────────────────────────────────────────────
|
|
1001
|
+
/**
|
|
1002
|
+
* The base image tag, derived from the `engines.node` the instance declares.
|
|
1003
|
+
*
|
|
1004
|
+
* R2.5a: a version this command chose would be a value nobody reviewed. The
|
|
1005
|
+
* major is what the range actually constrains, and a range with no digit in it
|
|
1006
|
+
* is not something a resolved manifest produces — the running interpreter's own
|
|
1007
|
+
* major is the fallback rather than a literal written here.
|
|
1008
|
+
*/
|
|
1009
|
+
export function nodeImage(enginesNode) {
|
|
1010
|
+
const major = /(\d+)/.exec(enginesNode)?.[1] ?? process.versions.node.split('.')[0];
|
|
1011
|
+
return `node:${major}-slim`;
|
|
1012
|
+
}
|
|
1013
|
+
/** `corepack`, activating the package manager the root manifest pins. */
|
|
1014
|
+
export function corepack(packageManager) {
|
|
1015
|
+
return packageManager === undefined
|
|
1016
|
+
? [
|
|
1017
|
+
'# Your root manifest pins no `packageManager`, so corepack takes its own',
|
|
1018
|
+
'# default. Pin one and this line names it instead.',
|
|
1019
|
+
'RUN corepack enable',
|
|
1020
|
+
]
|
|
1021
|
+
: [`RUN corepack enable && corepack prepare ${packageManager} --activate`];
|
|
1022
|
+
}
|
|
1023
|
+
/** The workspace manifests an install needs, which is one per written member. */
|
|
1024
|
+
function memberManifests(input) {
|
|
1025
|
+
return [
|
|
1026
|
+
'# The lockfile is one of these on purpose: `--frozen-lockfile` below refuses',
|
|
1027
|
+
'# to resolve a range, so an image is built from the versions you committed and',
|
|
1028
|
+
'# never from whatever the registry had that morning. Commit it.',
|
|
1029
|
+
'COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./',
|
|
1030
|
+
...(input.npmrc ? ['COPY .npmrc ./'] : []),
|
|
1031
|
+
'COPY backend/package.json backend/',
|
|
1032
|
+
...(input.admin ? ['COPY admin/package.json admin/'] : []),
|
|
1033
|
+
...(input.docs ? ['COPY docs/package.json docs/'] : []),
|
|
1034
|
+
];
|
|
1035
|
+
}
|
|
1036
|
+
/** The `docker build` line, with every `--build-arg` the declaration emits. */
|
|
1037
|
+
export function buildInvocation(target, path) {
|
|
1038
|
+
const flags = buildArgFlags(target);
|
|
1039
|
+
return [
|
|
1040
|
+
`# docker build -f ${path} \\`,
|
|
1041
|
+
...flags.map((flag) => `# ${flag} \\`),
|
|
1042
|
+
`# -t \${REGISTRY_IMAGE}/${target}:\${IMAGE_TAG} .`,
|
|
1043
|
+
];
|
|
1044
|
+
}
|
|
1045
|
+
/** The `ARG`/`ENV` pair per input this target's build reads. */
|
|
1046
|
+
export function argDeclarations(target) {
|
|
1047
|
+
return buildInputsFor(target).flatMap(({ input, consumer }) => [
|
|
1048
|
+
...wrapComment(input.meaning),
|
|
1049
|
+
`ARG ${consumer.buildArg}`,
|
|
1050
|
+
`ENV ${consumer.buildArg}=$${consumer.buildArg}`,
|
|
1051
|
+
]);
|
|
1052
|
+
}
|
|
1053
|
+
function backendDockerfile(input) {
|
|
1054
|
+
return `${[
|
|
1055
|
+
'# syntax=docker/dockerfile:1.7',
|
|
1056
|
+
'# An EXAMPLE image for this instance\'s backend: the API and, by default, the',
|
|
1057
|
+
'# queue consumers beside it. Copy it, change what you need, own it.',
|
|
1058
|
+
'#',
|
|
1059
|
+
'# Build from the root of this repository, so the workspace is visible:',
|
|
1060
|
+
...buildInvocation('backend', 'deploy/Dockerfile.backend'),
|
|
1061
|
+
'#',
|
|
1062
|
+
'# The backend runs BUILT output. `start`, `migrate` and every `module:*`',
|
|
1063
|
+
'# command name `dist/`, so the compile below is not an optimisation.',
|
|
1064
|
+
'',
|
|
1065
|
+
`FROM ${nodeImage(input.enginesNode)} AS base`,
|
|
1066
|
+
'ENV PNPM_HOME=/pnpm',
|
|
1067
|
+
'ENV PATH="$PNPM_HOME:$PATH"',
|
|
1068
|
+
'# argon2 ships a native addon; these cover a target with no prebuilt binary.',
|
|
1069
|
+
'RUN apt-get update \\',
|
|
1070
|
+
' && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \\',
|
|
1071
|
+
' && rm -rf /var/lib/apt/lists/*',
|
|
1072
|
+
...corepack(input.packageManager),
|
|
1073
|
+
'WORKDIR /app',
|
|
1074
|
+
'',
|
|
1075
|
+
'# --- dependencies: the manifests first, so a source edit does not reinstall ---',
|
|
1076
|
+
...memberManifests(input),
|
|
1077
|
+
'RUN pnpm install --frozen-lockfile',
|
|
1078
|
+
'',
|
|
1079
|
+
'# --- the application ---',
|
|
1080
|
+
'COPY . .',
|
|
1081
|
+
'# The layer\'s own build command, and not a second spelling of it. Change what',
|
|
1082
|
+
'# `build:backend` runs and this image follows with no edit here.',
|
|
1083
|
+
'RUN pnpm run build:backend',
|
|
1084
|
+
'',
|
|
1085
|
+
...argDeclarations('backend'),
|
|
1086
|
+
'',
|
|
1087
|
+
'ENV NODE_ENV=production',
|
|
1088
|
+
'ENV PORT=3001',
|
|
1089
|
+
'WORKDIR /app/backend',
|
|
1090
|
+
'EXPOSE 3001',
|
|
1091
|
+
'',
|
|
1092
|
+
'# The compose example overrides this for the one-shot migrate and install jobs.',
|
|
1093
|
+
'CMD ["node", "dist/index.js"]',
|
|
1094
|
+
].join('\n')}\n`;
|
|
1095
|
+
}
|
|
1096
|
+
function adminDockerfile(input) {
|
|
1097
|
+
return `${[
|
|
1098
|
+
'# syntax=docker/dockerfile:1.7',
|
|
1099
|
+
'# An EXAMPLE image for this instance\'s admin: a Vite build, served as static',
|
|
1100
|
+
'# files by nginx. It reaches the API from the browser and has no runtime',
|
|
1101
|
+
'# dependency on this tree, on Node, on the database, on Redis or on search —',
|
|
1102
|
+
'# which is what lets it live on a host of its own.',
|
|
1103
|
+
'#',
|
|
1104
|
+
'# Build from the root of this repository:',
|
|
1105
|
+
...buildInvocation('admin', 'deploy/Dockerfile.admin'),
|
|
1106
|
+
'#',
|
|
1107
|
+
'# THE BUNDLE IS BOUND TO ONE API ORIGIN AT BUILD TIME. Vite inlines the value',
|
|
1108
|
+
'# below into the JavaScript, so one bundle serves one backend: promoting this',
|
|
1109
|
+
'# image from staging to production is a REBUILD, not a redeploy.',
|
|
1110
|
+
'#',
|
|
1111
|
+
'# The build needs this installed workspace and cannot be done without one: the',
|
|
1112
|
+
'# admin\'s own build runs `endora generate` first, which renders its screen',
|
|
1113
|
+
'# registry over the module packages THIS instance installed. That dependency',
|
|
1114
|
+
'# is what keeps one module list governing all three deployables.',
|
|
1115
|
+
'',
|
|
1116
|
+
`FROM ${nodeImage(input.enginesNode)} AS build`,
|
|
1117
|
+
'ENV PNPM_HOME=/pnpm',
|
|
1118
|
+
'ENV PATH="$PNPM_HOME:$PATH"',
|
|
1119
|
+
...corepack(input.packageManager),
|
|
1120
|
+
'WORKDIR /app',
|
|
1121
|
+
'',
|
|
1122
|
+
...memberManifests(input),
|
|
1123
|
+
'RUN pnpm install --frozen-lockfile',
|
|
1124
|
+
'',
|
|
1125
|
+
'COPY . .',
|
|
1126
|
+
'',
|
|
1127
|
+
...argDeclarations('admin'),
|
|
1128
|
+
'# The layer\'s own build command. It runs `endora generate` first, so the',
|
|
1129
|
+
'# registry the bundle is built from is this install\'s.',
|
|
1130
|
+
'RUN pnpm run build:admin',
|
|
1131
|
+
'',
|
|
1132
|
+
'# --- runtime: static files, and nothing else ---',
|
|
1133
|
+
'FROM nginx:1.27-alpine AS run',
|
|
1134
|
+
'# A single-page application needs every unknown path to reach index.html, or a',
|
|
1135
|
+
'# reload of any screen but the first answers 404.',
|
|
1136
|
+
"RUN printf 'server {\\n listen 80;\\n root /usr/share/nginx/html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\",
|
|
1137
|
+
' > /etc/nginx/conf.d/default.conf',
|
|
1138
|
+
'COPY --from=build /app/admin/dist /usr/share/nginx/html',
|
|
1139
|
+
'EXPOSE 80',
|
|
1140
|
+
].join('\n')}\n`;
|
|
1141
|
+
}
|
|
1142
|
+
// ── the README ─────────────────────────────────────────────────────────────
|
|
1143
|
+
function deployReadme(input, written) {
|
|
1144
|
+
const files = written.filter((path) => path !== 'README.md');
|
|
1145
|
+
const singleHost = input.topology === 'single-host';
|
|
1146
|
+
return `${[
|
|
1147
|
+
'# Deploying this instance',
|
|
1148
|
+
'',
|
|
1149
|
+
'Everything in this directory is an **example**. None of it is applied by anything, none',
|
|
1150
|
+
'of it is read back by any command, and no value in it was chosen by anybody but you: the',
|
|
1151
|
+
'domains are `example.com`, the secrets say `change-me`, and the registry is nowhere. Copy',
|
|
1152
|
+
'what you need onto the machines you run, change it, and own it from then on.',
|
|
1153
|
+
'',
|
|
1154
|
+
`These files were written for the **${input.topology}** topology, which was a flag on the`,
|
|
1155
|
+
'command that scaffolded this tree. The choice is recorded in no file and read by nothing —',
|
|
1156
|
+
'a machine layout is a fact about your machines, and the one thing this repository states',
|
|
1157
|
+
'about itself is the module list in the root `package.json`. Scaffold again with the other',
|
|
1158
|
+
'topology if you want to see its examples; nothing here has to be undone first.',
|
|
1159
|
+
'',
|
|
1160
|
+
'## What is here',
|
|
1161
|
+
'',
|
|
1162
|
+
'| File | What it is |',
|
|
1163
|
+
'| --- | --- |',
|
|
1164
|
+
...files.map((path) => `| \`${path}\` | ${describeFile(path)} |`),
|
|
1165
|
+
'',
|
|
1166
|
+
'## Bringing it up',
|
|
1167
|
+
'',
|
|
1168
|
+
...(singleHost
|
|
1169
|
+
? [
|
|
1170
|
+
'One machine, one command:',
|
|
1171
|
+
'',
|
|
1172
|
+
'```',
|
|
1173
|
+
'docker compose --env-file .env -f compose.prod.yml up -d',
|
|
1174
|
+
'```',
|
|
1175
|
+
'',
|
|
1176
|
+
'The migration job runs first, then the install job (`module:install --all`, a no-op',
|
|
1177
|
+
'for a module already installed), and the API waits for both to exit 0, so a schema',
|
|
1178
|
+
'change and a new module\'s install hooks are applied before anything serves a request.',
|
|
1179
|
+
'Point your host nginx at the loopback ports (see `nginx.example.conf`) and let certbot',
|
|
1180
|
+
'handle TLS.',
|
|
1181
|
+
]
|
|
1182
|
+
: [
|
|
1183
|
+
'Three machines, in this order. The order matters once, on a first bring-up: the',
|
|
1184
|
+
'storefront\'s first page fetch and the admin\'s first API call both need a backend',
|
|
1185
|
+
'that has migrated.',
|
|
1186
|
+
'',
|
|
1187
|
+
'1. **The backend host** — it owns every stateful service, the migration and install',
|
|
1188
|
+
' jobs and the API:',
|
|
1189
|
+
'',
|
|
1190
|
+
' ```',
|
|
1191
|
+
' docker compose --env-file .env -f three-host/compose.backend.yml up -d',
|
|
1192
|
+
' ```',
|
|
1193
|
+
'',
|
|
1194
|
+
'2. **The storefront host**:',
|
|
1195
|
+
'',
|
|
1196
|
+
' ```',
|
|
1197
|
+
' docker compose --env-file .env -f three-host/compose.storefront.yml up -d',
|
|
1198
|
+
' ```',
|
|
1199
|
+
'',
|
|
1200
|
+
'3. **The admin host** — static files; it waits for nothing and can go up at any',
|
|
1201
|
+
' point:',
|
|
1202
|
+
'',
|
|
1203
|
+
' ```',
|
|
1204
|
+
' docker compose --env-file .env -f three-host/compose.admin.yml up -d',
|
|
1205
|
+
' ```',
|
|
1206
|
+
'',
|
|
1207
|
+
'**The three `.env` files are not interchangeable.** Each carries exactly the values',
|
|
1208
|
+
'its own compose file reads and nothing else — the backend\'s holds the database',
|
|
1209
|
+
'password and every encryption key, the storefront\'s holds one shared secret and two',
|
|
1210
|
+
'origins, the admin\'s holds an image tag and a port. Copying one onto another host',
|
|
1211
|
+
'either hands that host secrets it has no use for or leaves it starting with blanks.',
|
|
1212
|
+
'',
|
|
1213
|
+
'## What the split costs, and neither is in the diff',
|
|
1214
|
+
'',
|
|
1215
|
+
'Both are written into the files themselves, so an operator editing one meets them:',
|
|
1216
|
+
'',
|
|
1217
|
+
'1. **Every server-rendered storefront page gains a network round trip.** On one host',
|
|
1218
|
+
' the SSR fetch was a bridge hop; here it is a TLS request to another machine, on',
|
|
1219
|
+
' every render.',
|
|
1220
|
+
'2. **`REVALIDATE_SECRET` becomes a bearer secret on a public endpoint.** It never',
|
|
1221
|
+
' left a private network before; now it crosses whatever is between the two hosts',
|
|
1222
|
+
' on every catalogue change.',
|
|
1223
|
+
'',
|
|
1224
|
+
'## What replaces `depends_on` across hosts: nothing',
|
|
1225
|
+
'',
|
|
1226
|
+
'On one host the storefront waits for the backend to report healthy. That was a',
|
|
1227
|
+
'readiness convenience and never a correctness guarantee — the storefront starts fine',
|
|
1228
|
+
'without the backend and its first page fetch fails. Across hosts it is dropped and',
|
|
1229
|
+
'**not** replaced by a wait-for-it script, an init container or an orchestration',
|
|
1230
|
+
'dependency. Each layer starts, answers its own health check and tolerates an absent',
|
|
1231
|
+
'peer. The one edge whose loss would be a correctness bug — the API waiting for the',
|
|
1232
|
+
'migration job — is inside the backend host and is untouched.',
|
|
1233
|
+
]),
|
|
1234
|
+
'',
|
|
1235
|
+
'## Building the images',
|
|
1236
|
+
'',
|
|
1237
|
+
'The Dockerfiles here build from the **root of this repository**, because the admin bundle',
|
|
1238
|
+
'is rendered over the module packages this instance installed and cannot be built without',
|
|
1239
|
+
'the install. That is not a limitation to work around: it is what keeps one module list',
|
|
1240
|
+
'governing every artefact you deploy.',
|
|
1241
|
+
'',
|
|
1242
|
+
...(input.admin
|
|
1243
|
+
? [
|
|
1244
|
+
'One more consequence of it, stated because it surprises people: the admin bundle has',
|
|
1245
|
+
'its API origin inlined at build time. One bundle serves one backend, and moving an',
|
|
1246
|
+
'admin image from staging to production is a rebuild rather than a redeploy.',
|
|
1247
|
+
'',
|
|
1248
|
+
]
|
|
1249
|
+
: []),
|
|
1250
|
+
'The commands are in each file\'s own header, with every `--build-arg` the build reads.',
|
|
1251
|
+
]
|
|
1252
|
+
.join('\n')
|
|
1253
|
+
.replace(/\n+$/, '')}\n`;
|
|
1254
|
+
}
|
|
1255
|
+
function describeFile(path) {
|
|
1256
|
+
if (path === 'compose.prod.yml') {
|
|
1257
|
+
return 'every service on one machine: the database, the cache, the search engine, the migration job, the API, the storefront and the admin';
|
|
1258
|
+
}
|
|
1259
|
+
if (path === '.env.example')
|
|
1260
|
+
return 'what that stack reads on every start. Copy to `.env` beside it; `.env` is git-ignored';
|
|
1261
|
+
if (path === 'nginx.example.conf')
|
|
1262
|
+
return 'a reverse-proxy block per public name, for the nginx already on the host';
|
|
1263
|
+
if (path === 'three-host/compose.backend.yml') {
|
|
1264
|
+
return 'the backend machine: the database, the cache, the search engine, the migration job and the API. Every stateful service is here';
|
|
1265
|
+
}
|
|
1266
|
+
if (path === 'three-host/compose.storefront.yml')
|
|
1267
|
+
return 'the storefront machine: one service, reaching the API over its public origin';
|
|
1268
|
+
if (path === 'three-host/compose.admin.yml')
|
|
1269
|
+
return 'the admin machine: static files behind nginx, with no peer to wait for';
|
|
1270
|
+
if (path.startsWith('three-host/.env.')) {
|
|
1271
|
+
const host = path.slice('three-host/.env.'.length).replace('.example', '');
|
|
1272
|
+
return `what the ${host} machine reads. Copy to \`.env\` on **that** machine and no other`;
|
|
1273
|
+
}
|
|
1274
|
+
if (path === 'Dockerfile.backend')
|
|
1275
|
+
return 'an example image for the API and its queue consumers';
|
|
1276
|
+
if (path === 'Dockerfile.admin')
|
|
1277
|
+
return 'an example image for the admin bundle, served by nginx';
|
|
1278
|
+
return 'an example';
|
|
1279
|
+
}
|
|
1280
|
+
// ── the plan ───────────────────────────────────────────────────────────────
|
|
1281
|
+
const SINGLE_HOST_HEADER = [
|
|
1282
|
+
'# An EXAMPLE production stack for ONE machine.',
|
|
1283
|
+
'#',
|
|
1284
|
+
'# It pulls images by tag and wires them together; it builds nothing and it',
|
|
1285
|
+
'# terminates no TLS. Your own nginx does that — see nginx.example.conf — and',
|
|
1286
|
+
'# every service below is published on a loopback port for it to reach.',
|
|
1287
|
+
'#',
|
|
1288
|
+
'# docker compose --env-file .env -f compose.prod.yml up -d',
|
|
1289
|
+
'#',
|
|
1290
|
+
'# `.env` sits beside this file, is git-ignored, and is yours. Nothing here was',
|
|
1291
|
+
'# chosen by anybody else: see .env.example for what each value decides.',
|
|
1292
|
+
];
|
|
1293
|
+
function threeHostHeader(host, extra) {
|
|
1294
|
+
return [
|
|
1295
|
+
`# An EXAMPLE production stack for the ${host.toUpperCase()} machine, one of three.`,
|
|
1296
|
+
'#',
|
|
1297
|
+
`# docker compose --env-file .env -f compose.${host}.yml up -d`,
|
|
1298
|
+
'#',
|
|
1299
|
+
`# The \`.env\` this reads is \`.env.${host}.example\`, copied onto THAT machine.`,
|
|
1300
|
+
'# The three are not interchangeable: each carries exactly what its own file',
|
|
1301
|
+
'# reads, so copying one onto another host either hands it secrets it has no',
|
|
1302
|
+
'# use for or starts it with blanks. See README.md for the order.',
|
|
1303
|
+
...extra,
|
|
1304
|
+
];
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Every `deploy/` file this run writes.
|
|
1308
|
+
*
|
|
1309
|
+
* They are the **client's** kind: rendered once, edited by them, and read back
|
|
1310
|
+
* by nothing here. A file this command would read again would be a second home
|
|
1311
|
+
* for a fact the manifest already holds.
|
|
1312
|
+
*/
|
|
1313
|
+
export function deployFiles(input) {
|
|
1314
|
+
const all = services(input);
|
|
1315
|
+
const files = [];
|
|
1316
|
+
const write = (path, content) => {
|
|
1317
|
+
files.push({ path: `deploy/${path}`, kind: 'client', member: 'root', content });
|
|
1318
|
+
};
|
|
1319
|
+
if (input.topology === 'single-host') {
|
|
1320
|
+
const compose = composeDocument(SINGLE_HOST_HEADER, all, input.declared);
|
|
1321
|
+
write('compose.prod.yml', compose);
|
|
1322
|
+
write('.env.example', envExampleFor([
|
|
1323
|
+
'# What this stack reads on every start. Copy to `.env` beside this file and fill it',
|
|
1324
|
+
'# in; `.env` is git-ignored and nothing here is a value anybody but you chose.',
|
|
1325
|
+
'#',
|
|
1326
|
+
'# These are the values THIS compose file expands, for a stack on a host. The',
|
|
1327
|
+
'# `.env.example` at the root of this repository is the one a development machine',
|
|
1328
|
+
'# reads and it is a different set: it names the database and the cache directly,',
|
|
1329
|
+
'# where the services below are named by the compose network instead.',
|
|
1330
|
+
], compose, input.declared));
|
|
1331
|
+
write('nginx.example.conf', nginxExample(input));
|
|
1332
|
+
}
|
|
1333
|
+
else {
|
|
1334
|
+
const hosts = input.admin
|
|
1335
|
+
? ['backend', 'storefront', 'admin']
|
|
1336
|
+
: ['backend', 'storefront'];
|
|
1337
|
+
for (const host of hosts) {
|
|
1338
|
+
const chosen = all.filter((service) => service.host === host);
|
|
1339
|
+
const compose = composeDocument(threeHostHeader(host, extraHeaderFor(host)), chosen, input.declared);
|
|
1340
|
+
write(`three-host/compose.${host}.yml`, compose);
|
|
1341
|
+
write(`three-host/.env.${host}.example`, envExampleFor([
|
|
1342
|
+
`# What the ${host.toUpperCase()} machine reads on every start. Copy to \`.env\` on`,
|
|
1343
|
+
'# THAT machine and on no other — this file holds exactly the values',
|
|
1344
|
+
`# \`compose.${host}.yml\` expands, and the other two hosts hold theirs.`,
|
|
1345
|
+
], compose, input.declared));
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
write('Dockerfile.backend', backendDockerfile(input));
|
|
1349
|
+
if (input.admin)
|
|
1350
|
+
write('Dockerfile.admin', adminDockerfile(input));
|
|
1351
|
+
write('README.md', deployReadme(input, files.map((file) => file.path.slice('deploy/'.length))));
|
|
1352
|
+
return files;
|
|
1353
|
+
}
|
|
1354
|
+
/** The one sentence each host's header owes beyond the shared four. */
|
|
1355
|
+
function extraHeaderFor(host) {
|
|
1356
|
+
if (host === 'backend') {
|
|
1357
|
+
return [
|
|
1358
|
+
'#',
|
|
1359
|
+
'# Every stateful service is here — the database, the cache and the search',
|
|
1360
|
+
'# engine — along with the migration job. Putting one of them on another',
|
|
1361
|
+
'# machine is a layout these examples have not measured.',
|
|
1362
|
+
];
|
|
1363
|
+
}
|
|
1364
|
+
if (host === 'storefront') {
|
|
1365
|
+
return [
|
|
1366
|
+
'#',
|
|
1367
|
+
'# One service, reaching the backend over its PUBLIC origin. It waits for',
|
|
1368
|
+
'# nothing: it starts without the backend and its first page fetch fails,',
|
|
1369
|
+
'# which is what the single-host `depends_on` bought and what is deliberately',
|
|
1370
|
+
'# not replaced here by a wait-for-it script or an init container.',
|
|
1371
|
+
];
|
|
1372
|
+
}
|
|
1373
|
+
return [
|
|
1374
|
+
'#',
|
|
1375
|
+
'# Static files behind nginx. No database, no cache, no search, no Node and no',
|
|
1376
|
+
'# `depends_on` — it talks to the API from the browser. It is the cheapest of',
|
|
1377
|
+
'# the three to move, and the one whose bundle is bound to one API origin at',
|
|
1378
|
+
'# build time: promoting it between environments is a rebuild.',
|
|
1379
|
+
];
|
|
1380
|
+
}
|
|
1381
|
+
//# sourceMappingURL=deploy.js.map
|