underpost 3.2.80 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,1083 @@
1
+ /**
2
+ * The centralized gateway infrastructure service.
3
+ *
4
+ * One Nginx deployment is the cluster's single edge utility layer: it serves
5
+ * every host's status pages, maintenance pages and shared edge contexts, and it
6
+ * reverse-proxies the application workloads whose errors it is asked to
7
+ * intercept. Application runtimes stay agnostic — they return a standard status
8
+ * code or become unreachable, and nothing about status page delivery lives in
9
+ * them.
10
+ *
11
+ * Interception is Nginx's `proxy_intercept_errors`, not an Envoy response
12
+ * substitution, because only it satisfies all three constraints at once: the
13
+ * document is served from disk so its size is unbounded, the upstream's status
14
+ * code is preserved, and the client's URI never changes. Envoy's own mechanisms
15
+ * substitute an inline body capped at 4096 bytes, and Envoy cannot re-dispatch a
16
+ * request to another cluster once the upstream has answered.
17
+ *
18
+ * Layout under the Nginx root:
19
+ * <root>/<host>/<path>/status-pages/<status>/index.html
20
+ * <root>/<host>/<path>/<context>/...
21
+ * <root>/conf.d/<host>.conf generated server blocks
22
+ * where `<path>` is the proxy sub-path with `/` written as `root`, so
23
+ * `www.cyberiaonline.com` + `/` + 404 becomes
24
+ * `www.cyberiaonline.com/root/status-pages/404/index.html`.
25
+ *
26
+ * @module src/server/underpost-gateway.js
27
+ * @namespace UnderpostGateway
28
+ */
29
+
30
+ import crypto from 'node:crypto';
31
+ import fs from 'fs-extra';
32
+ import nodePath from 'node:path';
33
+ import { timer } from '../client/components/core/CommonJs.js';
34
+ import { instanceStatusPageEntriesFactory, loadConfServerJson, loadReplicas } from './conf.js';
35
+ import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
36
+ import Underpost from '../index.js';
37
+ import { loggerFactory } from './logger.js';
38
+ import { shellExec } from './process.js';
39
+
40
+ const logger = loggerFactory(import.meta);
41
+
42
+ /**
43
+ * @constant UNDERPOST_GATEWAY
44
+ * @description Identity of the shared gateway workload. One deployment serves
45
+ * every host of every deploy, so these names are cluster-wide constants rather
46
+ * than per-deploy.
47
+ * @memberof UnderpostGateway
48
+ */
49
+ const UNDERPOST_GATEWAY = {
50
+ name: 'underpost-gateway',
51
+ serviceName: 'underpost-gateway-service',
52
+ configMapName: 'underpost-gateway-nginx',
53
+ claimName: 'pvc-underpost-gateway',
54
+ volumeName: 'pv-underpost-gateway',
55
+ image: nginxImageFactory(),
56
+ root: '/var/www/static',
57
+ port: 80,
58
+ healthPath: '/healthz',
59
+ defaultHostDir: 'default',
60
+ confDir: 'conf.d',
61
+ // kube-dns's conventional ClusterIP; overridden from the live Service.
62
+ resolver: '10.96.0.10',
63
+ };
64
+
65
+ /**
66
+ * @method staticPathSegmentFactory
67
+ * @description Folds a proxy sub-path into one directory name. `/` has no
68
+ * directory of its own, so it is written as `root`; anything else keeps its
69
+ * segments joined by `-` to stay a single level under the host.
70
+ * @param {string} [path] - Proxy sub-path (`/`, `/peer`, `/app`).
71
+ * @returns {string} Directory name.
72
+ * @memberof UnderpostGateway
73
+ */
74
+ const staticPathSegmentFactory = (path = '/') => {
75
+ const segment = `${path || '/'}`.replace(/^\/+|\/+$/g, '').replace(/\//g, '-');
76
+ return segment || 'root';
77
+ };
78
+
79
+ /**
80
+ * @method kubernetesUpstreamFactory
81
+ * @description Qualifies a short Kubernetes Service name for Nginx's runtime DNS resolver.
82
+ *
83
+ * Nginx uses the configured `resolver` whenever `proxy_pass` contains a
84
+ * variable. That resolver does not apply the pod's `/etc/resolv.conf` search
85
+ * suffixes, so `service-name:80` fails even though the same short name works in
86
+ * wget/curl. A fully-qualified Service DNS name is unambiguous and continues to
87
+ * resolve after the Service is recreated.
88
+ *
89
+ * Already-qualified hosts and IP literals are kept as supplied.
90
+ * @param {string} upstream - `host:port`.
91
+ * @param {string} [namespace] - Kubernetes namespace containing the Service.
92
+ * @returns {string} Runtime-resolvable upstream.
93
+ * @memberof UnderpostGateway
94
+ */
95
+ const kubernetesUpstreamFactory = (upstream, namespace = 'default') => {
96
+ const value = `${upstream || ''}`.trim();
97
+ const separator = value.lastIndexOf(':');
98
+ if (separator < 1) return value;
99
+ const host = value.slice(0, separator);
100
+ const port = value.slice(separator + 1);
101
+ if (host.includes('.') || host === 'localhost' || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return value;
102
+ return `${host}.${namespace}.svc.cluster.local:${port}`;
103
+ };
104
+
105
+ /**
106
+ * @method nginxTokenFactory
107
+ * @description A sub-path as an identifier nginx accepts. Variable names admit
108
+ * only word characters, so the `-` that {@link UnderpostGateway.staticPathSegmentFactory}
109
+ * joins multi-segment paths with cannot appear in one.
110
+ * @param {string} [path] - Proxy sub-path.
111
+ * @returns {string} Identifier-safe token.
112
+ * @memberof UnderpostGateway
113
+ */
114
+ const nginxTokenFactory = (path = '/') => staticPathSegmentFactory(path).replace(/[^a-zA-Z0-9]/g, '_');
115
+
116
+ /**
117
+ * @method staticLocationFactory
118
+ * @description The three forms every placed document is addressed by: where it
119
+ * sits under the root, the directory a prefix rewrite targets, and the exact URL
120
+ * a full-path rewrite targets.
121
+ *
122
+ * `dir` is what routes normally use. A `ReplacePrefixMatch` onto the directory
123
+ * lets one rule cover the document *and* everything beside it — `/maintenance`
124
+ * resolves through `try_files $uri/index.html`, while `/maintenance/logo.png`
125
+ * resolves through `$uri` — which is why the layout keeps each context in its
126
+ * own directory rather than as a bare file.
127
+ * @param {string} host - Hostname the document belongs to.
128
+ * @param {string} [path] - Proxy sub-path the document belongs to.
129
+ * @param {string} context - Directory under the sub-path (`status-pages/404`, `maintenance`).
130
+ * @param {string} [file] - Document within the context.
131
+ * @returns {{ assetPath: string, dir: string, url: string }} Root-relative path, prefix target, full-path target.
132
+ * @memberof UnderpostGateway
133
+ */
134
+ const staticLocationFactory = ({ host, path = '/', context, file = 'index.html' }) => {
135
+ const dir = `${host}/${staticPathSegmentFactory(path)}/${context}`;
136
+ const assetPath = `${dir}/${file}`;
137
+ return { assetPath, dir: `/${dir}`, url: `/${assetPath}` };
138
+ };
139
+
140
+ /**
141
+ * @method statusPageAssetPathFactory
142
+ * @description Location of one host's status page.
143
+ * @param {string} host - Hostname the page belongs to.
144
+ * @param {string} [path] - Proxy sub-path the page belongs to.
145
+ * @param {string|number} status - HTTP status code.
146
+ * @returns {{ assetPath: string, dir: string, url: string }} See {@link UnderpostGateway.staticLocationFactory}.
147
+ * @memberof UnderpostGateway
148
+ */
149
+ const statusPageAssetPathFactory = ({ host, path = '/', status }) =>
150
+ staticLocationFactory({ host, path, context: `status-pages/${status}` });
151
+
152
+ /**
153
+ * @method statusPageBuildSegment
154
+ * @description Where a status page is built inside the client bundle, relative
155
+ * to that client's served root.
156
+ *
157
+ * Deliberately not `<status>/index.html`. A status page is an edge artifact, and
158
+ * a document sitting on the runtime's own `/<status>` route makes it an
159
+ * application route too: the runtime then has a page to serve — or to redirect to
160
+ * — for its own errors, which is exactly the URI change the edge exists to
161
+ * prevent. Kept under the same `status-pages` name the gateway layout uses, so
162
+ * both sides read one convention.
163
+ * @param {string|number} status - HTTP status code.
164
+ * @returns {string} Bundle-relative path of the document.
165
+ * @memberof UnderpostGateway
166
+ */
167
+ const statusPageBuildSegment = (status) => `status-pages/${status}/index.html`;
168
+
169
+ /**
170
+ * @method defaultStatusPagePath
171
+ * @description Root-relative location of the shared fallback document.
172
+ * @param {string|number} [status] - HTTP status code.
173
+ * @returns {string} Root-relative path.
174
+ * @memberof UnderpostGateway
175
+ */
176
+ const defaultStatusPagePath = (status = 404) => `${UNDERPOST_GATEWAY.defaultHostDir}/status-pages/${status}/index.html`;
177
+
178
+ /**
179
+ * @method nginxConfFactory
180
+ * @description Renders the server config.
181
+ *
182
+ * The gateway always rewrites into the layout before forwarding, so `try_files`
183
+ * only has to resolve a path that is already root-relative: the document itself
184
+ * for an asset request, then `index.html` beneath it for a directory — which is
185
+ * what a `ReplacePrefixMatch` onto a context directory produces.
186
+ *
187
+ * A miss ends on the shared default page, so a host with nothing on disk still
188
+ * answers with a deliberate document — but through `error_page`, which keeps the
189
+ * 404 status rather than presenting the fallback as the host's own page with a
190
+ * 200. That status is what stops the document being stored: the PWA service
191
+ * worker caches navigations for hours and would otherwise keep serving the
192
+ * fallback long after the host's real page landed in the tree.
193
+ *
194
+ * Compression is applied here rather than further out because this is the last
195
+ * hop that holds a response body in the clear — and, for every host whose
196
+ * errors are intercepted, the hop every site path already passes through. See
197
+ * {@link module:src/server/underpost-compression.js}.
198
+ * @param {string} [resolver] - Cluster DNS ClusterIP.
199
+ * @param {object} [compression] - Overrides for the compression policy.
200
+ * @returns {string} nginx.conf contents.
201
+ * @memberof UnderpostGateway
202
+ */
203
+ const nginxConfFactory = ({ resolver = UNDERPOST_GATEWAY.resolver, compression = {} } = {}) => {
204
+ const policy = { ...compression, staticRoot: true };
205
+ const modules = compressionModulesConfFactory(policy);
206
+ const compress = compressionConfFactory(policy);
207
+ return `worker_processes auto;
208
+ error_log /dev/stderr warn;
209
+ pid /tmp/nginx.pid;
210
+ ${modules ? `${modules}\n` : ''}
211
+ events {
212
+ worker_connections 1024;
213
+ }
214
+
215
+ http {
216
+ include /etc/nginx/mime.types;
217
+ default_type text/html;
218
+ sendfile on;
219
+ tcp_nopush on;
220
+ server_tokens off;
221
+ ${compress ? `\n${compress}\n` : ''}
222
+ log_format concise '$remote_addr "$request" $status $body_bytes_sent "$host"';
223
+ access_log /dev/stdout concise;
224
+
225
+ # Websocket upgrades must be forwarded verbatim; a proxied hop that drops the
226
+ # Connection header leaves the client holding a half-open socket.
227
+ map $http_upgrade $connection_upgrade {
228
+ default upgrade;
229
+ '' close;
230
+ }
231
+
232
+ # Cluster DNS as a literal address — nginx cannot resolve its own resolver.
233
+ # Needed because every upstream is passed through a variable: without it nginx
234
+ # resolves a Service name once at start-up and keeps the address for the life
235
+ # of the process, and a redeployed workload gets a new ClusterIP.
236
+ resolver ${resolver} valid=10s ipv6=off;
237
+
238
+ # Per-host server blocks, written into the volume by the deploy that owns the
239
+ # host. They live beside the documents rather than in this ConfigMap because
240
+ # the workload is shared: one deploy must not rewrite another's routing.
241
+ include ${UNDERPOST_GATEWAY.root}/${UNDERPOST_GATEWAY.confDir}/*.conf;
242
+
243
+ server {
244
+ listen ${UNDERPOST_GATEWAY.port} default_server;
245
+ server_name _;
246
+ root ${UNDERPOST_GATEWAY.root};
247
+
248
+ error_page 404 /${defaultStatusPagePath(404)};
249
+
250
+ # Probes are the only traffic that would otherwise dominate the log.
251
+ location = ${UNDERPOST_GATEWAY.healthPath} {
252
+ access_log off;
253
+ add_header Content-Type text/plain;
254
+ return 200 'ok';
255
+ }
256
+
257
+ location = /${defaultStatusPagePath(404)} {
258
+ internal;
259
+ add_header Cache-Control 'no-store' always;
260
+ }
261
+
262
+ location / {
263
+ add_header Cache-Control 'public, max-age=60';
264
+ try_files $uri $uri/index.html =404;
265
+ }
266
+ }
267
+ }
268
+ `;
269
+ };
270
+
271
+ /**
272
+ * @method statusPageLocationsFactory
273
+ * @description The internal locations an intercepted status resolves to, and the
274
+ * `error_page` lines that reach them.
275
+ *
276
+ * Each is `internal`, so a client cannot request the document at its storage
277
+ * path — it is only ever reached by interception, which is what keeps the
278
+ * client's URI unchanged. The `=` form is deliberately absent: `error_page 404
279
+ * /x` preserves the upstream's status, while `error_page 404 = /x` would rewrite
280
+ * it to the status of the page itself.
281
+ * @param {string} host - Hostname the documents belong to.
282
+ * @param {string} [path] - Proxy sub-path the documents belong to.
283
+ * @param {Object<string,string>} statuses - Status code → context directory under the sub-path.
284
+ * @returns {{errorPages: string, locations: string}} Rendered `error_page` directives and their locations.
285
+ * @memberof UnderpostGateway
286
+ */
287
+ const statusPageLocationsFactory = ({ host, path = '/', statuses }) => {
288
+ const entries = Object.entries(statuses);
289
+ const named = (status) => `@status_${nginxTokenFactory(path)}_${status}`;
290
+ return {
291
+ errorPages: entries.map(([status]) => ` error_page ${status} ${named(status)};`).join('\n'),
292
+ locations: entries
293
+ .map(
294
+ ([status, context]) => ` location ${named(status)} {
295
+ add_header Cache-Control 'no-store' always;
296
+ try_files /${staticLocationFactory({ host, path, context }).assetPath} =${status};
297
+ }`,
298
+ )
299
+ .join('\n'),
300
+ };
301
+ };
302
+
303
+ /**
304
+ * @method hostServerConfFactory
305
+ * @description Renders one host's server block: every proxied sub-path, and the
306
+ * documents its errors are intercepted with.
307
+ *
308
+ * `proxy_intercept_errors` is the whole mechanism. The upstream answers 404 or
309
+ * dies, Nginx swaps in the document from disk, and the client sees its own URI
310
+ * with the upstream's status code — no redirect, no size ceiling, and nothing
311
+ * for the application runtime to implement. A sub-path that declares no status
312
+ * page is proxied untouched, so an API keeps returning its own error bodies.
313
+ * @param {string} host - Hostname this block serves.
314
+ * @param {Array<object>} routes - `{ path, upstream, statuses, stripPrefix }` per proxied
315
+ * sub-path; `statuses` maps a status code to the context directory holding its
316
+ * document, and `stripPrefix` drops the sub-path before dialling the upstream.
317
+ * @returns {string} nginx server block, or an empty string when the host proxies nothing.
318
+ * @memberof UnderpostGateway
319
+ */
320
+ const hostServerConfFactory = ({ host, routes = [], namespace = 'default' }) => {
321
+ const proxied = routes.filter((route) => route.upstream);
322
+ if (proxied.length === 0) return '';
323
+ // Longest sub-path first: nginx prefix locations are longest-match, but the
324
+ // emitted order keeps the block readable next to the HTTPRoute it mirrors.
325
+ const sorted = [...proxied].sort((a, b) => (b.path || '/').length - (a.path || '/').length);
326
+ const blocks = sorted.map((route) => {
327
+ const { errorPages, locations } = statusPageLocationsFactory({
328
+ host,
329
+ path: route.path,
330
+ statuses: route.statuses || {},
331
+ });
332
+ const intercept = errorPages ? ` proxy_intercept_errors on;\n${errorPages}` : ' proxy_intercept_errors off;';
333
+ const path = route.path || '/';
334
+ // The upstream is dialled through a variable, so nginx forwards the request
335
+ // URI verbatim and a prefix strip has to be an explicit rewrite. `break`
336
+ // keeps it inside this location instead of re-running location matching.
337
+ const rewrite = route.stripPrefix && path !== '/' ? ` rewrite ^${path}/?(.*)$ /$1 break;\n` : '';
338
+ return {
339
+ path,
340
+ upstream: kubernetesUpstreamFactory(route.upstream, namespace),
341
+ intercept,
342
+ locations,
343
+ rewrite,
344
+ };
345
+ });
346
+ return `
347
+ server {
348
+ listen ${UNDERPOST_GATEWAY.port};
349
+ server_name ${host};
350
+ root ${UNDERPOST_GATEWAY.root};
351
+
352
+ # This host's own documents, served from disk before anything is proxied.
353
+ # Every path in the layout begins with the hostname, which is exactly what the
354
+ # gateway rewrites a status or context route onto — and a longer prefix than
355
+ # the proxied root below, so nginx prefers it. Without this location the
356
+ # rewritten document path falls into the proxy and is sent to the application,
357
+ # which has no such route: the app answers 404, and an app that redirects its
358
+ # own 404s turns that into a loop between the route and the rewrite.
359
+ location /${host}/ {
360
+ add_header Cache-Control 'public, max-age=60';
361
+ try_files $uri $uri/index.html =404;
362
+ }
363
+
364
+ ${blocks
365
+ .map(
366
+ ({ path, upstream, intercept, rewrite }) => ` location ${path} {
367
+ proxy_http_version 1.1;
368
+ proxy_set_header Host $host;
369
+ proxy_set_header X-Real-IP $remote_addr;
370
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
371
+ proxy_set_header X-Forwarded-Proto $scheme;
372
+ # Websockets: the upgrade must survive this hop or a client that negotiated
373
+ # one at the edge is left holding a half-open connection.
374
+ proxy_set_header Upgrade $http_upgrade;
375
+ proxy_set_header Connection $connection_upgrade;
376
+ set $upstream_${nginxTokenFactory(path)} ${upstream};
377
+ ${intercept}
378
+ ${rewrite} proxy_pass http://$upstream_${nginxTokenFactory(path)};
379
+ }`,
380
+ )
381
+ .join('\n\n')}
382
+
383
+ ${blocks
384
+ .map(({ locations }) => locations)
385
+ .filter(Boolean)
386
+ .join('\n')}
387
+ }
388
+ `;
389
+ };
390
+
391
+ /**
392
+ * @method underpostGatewayManifestsFactory
393
+ * @description Renders the whole workload: the Nginx config, the hostPath volume
394
+ * holding the documents, the deployment and the Service routes target.
395
+ * @param {string} [namespace] - Kubernetes namespace.
396
+ * @param {string} hostPath - Node directory backing the static root.
397
+ * @param {string} [nodeName] - Node the hostPath volume is pinned to.
398
+ * @param {string} [storage] - Volume size.
399
+ * @param {object} [compression] - Overrides for the compression policy.
400
+ * @returns {string} Multi-document YAML.
401
+ * @memberof UnderpostGateway
402
+ */
403
+ const underpostGatewayManifestsFactory = ({
404
+ namespace = 'default',
405
+ hostPath,
406
+ nodeName = '',
407
+ storage = '1Gi',
408
+ resolver,
409
+ compression = {},
410
+ } = {}) => {
411
+ const nginxConf = nginxConfFactory({ resolver, compression });
412
+ // The config is mounted with `subPath`, which Kubernetes never refreshes in
413
+ // place, and the pod template is otherwise identical across rebuilds — so
414
+ // without this annotation an edited nginx.conf reaches the ConfigMap and
415
+ // nothing else, and the running Nginx keeps serving under the previous
416
+ // layout for the life of the pod.
417
+ const configHash = crypto.createHash('sha256').update(nginxConf).digest('hex').slice(0, 16);
418
+ return `
419
+ ---
420
+ apiVersion: v1
421
+ kind: ConfigMap
422
+ metadata:
423
+ name: ${UNDERPOST_GATEWAY.configMapName}
424
+ namespace: ${namespace}
425
+ data:
426
+ nginx.conf: |
427
+ ${nginxConf
428
+ .replace(/\n$/, '')
429
+ .split('\n')
430
+ .map((line) => (line.length > 0 ? ` ${line}` : ''))
431
+ .join('\n')}
432
+ ---
433
+ apiVersion: v1
434
+ kind: PersistentVolume
435
+ metadata:
436
+ name: ${UNDERPOST_GATEWAY.volumeName}
437
+ spec:
438
+ capacity:
439
+ storage: ${storage}
440
+ accessModes:
441
+ - ReadOnlyMany
442
+ - ReadWriteOnce
443
+ persistentVolumeReclaimPolicy: Retain
444
+ storageClassName: manual${
445
+ nodeName
446
+ ? `
447
+ nodeAffinity:
448
+ required:
449
+ nodeSelectorTerms:
450
+ - matchExpressions:
451
+ - key: kubernetes.io/hostname
452
+ operator: In
453
+ values:
454
+ - ${nodeName}`
455
+ : ''
456
+ }
457
+ claimRef:
458
+ apiVersion: v1
459
+ kind: PersistentVolumeClaim
460
+ name: ${UNDERPOST_GATEWAY.claimName}
461
+ namespace: ${namespace}
462
+ hostPath:
463
+ path: ${hostPath}
464
+ type: DirectoryOrCreate
465
+ ---
466
+ apiVersion: v1
467
+ kind: PersistentVolumeClaim
468
+ metadata:
469
+ name: ${UNDERPOST_GATEWAY.claimName}
470
+ namespace: ${namespace}
471
+ spec:
472
+ accessModes:
473
+ - ReadWriteOnce
474
+ storageClassName: manual
475
+ volumeName: ${UNDERPOST_GATEWAY.volumeName}
476
+ resources:
477
+ requests:
478
+ storage: ${storage}
479
+ ---
480
+ apiVersion: apps/v1
481
+ kind: Deployment
482
+ metadata:
483
+ name: ${UNDERPOST_GATEWAY.name}
484
+ namespace: ${namespace}
485
+ labels:
486
+ app: ${UNDERPOST_GATEWAY.name}
487
+ spec:
488
+ replicas: 1
489
+ selector:
490
+ matchLabels:
491
+ app: ${UNDERPOST_GATEWAY.name}
492
+ template:
493
+ metadata:
494
+ labels:
495
+ app: ${UNDERPOST_GATEWAY.name}
496
+ annotations:
497
+ underpost.net/nginx-conf-hash: '${configHash}'
498
+ spec:
499
+ containers:
500
+ - name: nginx
501
+ image: ${UNDERPOST_GATEWAY.image}
502
+ ports:
503
+ - containerPort: ${UNDERPOST_GATEWAY.port}
504
+ resources:
505
+ requests:
506
+ cpu: 10m
507
+ memory: 16Mi
508
+ limits:
509
+ cpu: 200m
510
+ memory: 128Mi
511
+ readinessProbe:
512
+ httpGet:
513
+ path: ${UNDERPOST_GATEWAY.healthPath}
514
+ port: ${UNDERPOST_GATEWAY.port}
515
+ initialDelaySeconds: 2
516
+ periodSeconds: 10
517
+ livenessProbe:
518
+ httpGet:
519
+ path: ${UNDERPOST_GATEWAY.healthPath}
520
+ port: ${UNDERPOST_GATEWAY.port}
521
+ initialDelaySeconds: 10
522
+ periodSeconds: 20
523
+ volumeMounts:
524
+ - name: nginx-conf
525
+ mountPath: /etc/nginx/nginx.conf
526
+ subPath: nginx.conf
527
+ - name: static-root
528
+ mountPath: ${UNDERPOST_GATEWAY.root}
529
+ readOnly: true
530
+ volumes:
531
+ - name: nginx-conf
532
+ configMap:
533
+ name: ${UNDERPOST_GATEWAY.configMapName}
534
+ - name: static-root
535
+ persistentVolumeClaim:
536
+ claimName: ${UNDERPOST_GATEWAY.claimName}
537
+ ---
538
+ apiVersion: v1
539
+ kind: Service
540
+ metadata:
541
+ name: ${UNDERPOST_GATEWAY.serviceName}
542
+ namespace: ${namespace}
543
+ labels:
544
+ app: ${UNDERPOST_GATEWAY.name}
545
+ spec:
546
+ type: ClusterIP
547
+ selector:
548
+ app: ${UNDERPOST_GATEWAY.name}
549
+ ports:
550
+ - name: http
551
+ protocol: TCP
552
+ port: ${UNDERPOST_GATEWAY.port}
553
+ targetPort: ${UNDERPOST_GATEWAY.port}
554
+ `;
555
+ };
556
+
557
+ /**
558
+ * @method writeStaticAsset
559
+ * @description Places one document in the node directory backing the static
560
+ * root. The deploy runs on that node, so the file is copied directly rather than
561
+ * shipped through the API server — which is also what keeps a page of any size
562
+ * out of the cluster's object store.
563
+ * @param {string} hostRoot - Node directory backing the static root.
564
+ * @param {string} assetPath - Root-relative destination.
565
+ * @param {string} sourcePath - File to copy.
566
+ * @returns {boolean} True when the document was placed.
567
+ * @memberof UnderpostGateway
568
+ */
569
+ const writeStaticAsset = ({ hostRoot, assetPath, sourcePath }) => {
570
+ if (!sourcePath || !fs.existsSync(sourcePath) || fs.statSync(sourcePath).size === 0) return false;
571
+ const target = nodePath.join(hostRoot, assetPath);
572
+ // sudo: the node directory is root-owned, and the deploy may run unprivileged.
573
+ shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
574
+ shellExec(`sudo cp -f ${sourcePath} ${target}`, { silent: true });
575
+ return true;
576
+ };
577
+
578
+ /**
579
+ * @method syncStaticAssetFromPod
580
+ * @description Pulls one document out of the running workload and places it in
581
+ * the static tree.
582
+ *
583
+ * The pod is the authority for these artifacts, not the host: several clients
584
+ * are built from sources that only exist inside the container (cloned from the
585
+ * private repo at start-up), so the host's `public/` tree is both incomplete and
586
+ * as old as the last host-side build. Copying from the pod is what makes the
587
+ * placed document match what the workload would actually have served.
588
+ * @param {string} podName - Workload pod holding the built artifact.
589
+ * @param {string} [namespace] - Pod namespace.
590
+ * @param {string} [container] - Container within the pod.
591
+ * @param {string} sourcePath - Absolute path of the artifact inside the container.
592
+ * @param {string} hostRoot - Node directory backing the static root.
593
+ * @param {string} assetPath - Root-relative destination.
594
+ * @returns {boolean} True when the document was placed.
595
+ * @memberof UnderpostGateway
596
+ */
597
+ const syncStaticAssetFromPod = ({ podName, namespace = 'default', container, sourcePath, hostRoot, assetPath }) => {
598
+ const target = nodePath.join(hostRoot, assetPath);
599
+ const containerFlag = container ? ` -c ${container}` : '';
600
+ // Keyed by destination, not by basename: every document in the layout is an
601
+ // `index.html`, so a shared staging name lets one asset's copy be mistaken
602
+ // for another's.
603
+ const staged = nodePath.join(
604
+ '/tmp',
605
+ `underpost-gateway-${crypto.createHash('sha256').update(assetPath).digest('hex').slice(0, 12)}-${process.pid}`,
606
+ );
607
+ // Staged through /tmp because `kubectl cp` runs unprivileged while the node
608
+ // directory is root-owned; the move is the only step that needs sudo.
609
+ fs.removeSync(staged);
610
+ shellExec(`kubectl cp ${namespace}/${podName}:${sourcePath} ${staged}${containerFlag} 2>/dev/null || true`, {
611
+ silent: true,
612
+ silentOnError: true,
613
+ });
614
+ if (!fs.existsSync(staged) || fs.statSync(staged).size === 0) {
615
+ fs.removeSync(staged);
616
+ return false;
617
+ }
618
+ shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
619
+ shellExec(`sudo cp -f ${staged} ${target}`, { silent: true });
620
+ fs.removeSync(staged);
621
+ logger.info('Static asset synced from workload', { podName, sourcePath, assetPath });
622
+ return true;
623
+ };
624
+
625
+ /**
626
+ * @method writeHostServerConf
627
+ * @description Writes one host's server block into a directory, or removes it
628
+ * when there is nothing to serve.
629
+ *
630
+ * A build artifact and nothing more: it touches no cluster, so generating
631
+ * manifests works with no cluster running at all. Installing the block into the
632
+ * live gateway and reloading it is {@link UnderpostGateway.installGatewayConf}'s
633
+ * job, on the apply path where a cluster is a precondition.
634
+ * @param {string} confDir - Directory the block is written to.
635
+ * @param {string} host - Hostname the block serves.
636
+ * @param {string} conf - Rendered block from {@link UnderpostGateway.hostServerConfFactory}; empty removes it.
637
+ * @returns {boolean} True when the file changed.
638
+ * @memberof UnderpostGateway
639
+ */
640
+ const writeHostServerConf = ({ confDir, host, conf }) => {
641
+ const target = nodePath.join(confDir, `${host}.conf`);
642
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
643
+ if (current === (conf || '')) return false;
644
+ if (!conf) {
645
+ fs.removeSync(target);
646
+ return true;
647
+ }
648
+ fs.mkdirpSync(confDir);
649
+ fs.writeFileSync(target, conf, 'utf8');
650
+ return true;
651
+ };
652
+
653
+ /**
654
+ * @method hostInstanceRegistryPathFactory
655
+ * @description Path of the descriptor set last published for a host.
656
+ *
657
+ * Kept beside the host's server block because it describes the same object, and
658
+ * given a non-`.conf` suffix so {@link UnderpostGateway.installGatewayConf}
659
+ * never installs it into Nginx.
660
+ * @param {string} confDir - Directory holding the built blocks.
661
+ * @param {string} host - Hostname.
662
+ * @returns {string} File path.
663
+ * @memberof UnderpostGateway
664
+ */
665
+ const hostInstanceRegistryPathFactory = ({ confDir, host }) => nodePath.join(confDir, `${host}.instances.json`);
666
+
667
+ /**
668
+ * @method readHostInstanceRegistry
669
+ * @description The instance descriptors last published for a host.
670
+ *
671
+ * The conf declares what *should* run; this records what the host was last
672
+ * rendered with, which is the only place a variant's descriptor survives being
673
+ * removed from the conf while its workload is still up. Unreadable or malformed
674
+ * content is treated as absent: a broken registry must not block a deploy, it
675
+ * just means nothing extra is preserved.
676
+ * @param {string} confDir - Directory holding the built blocks.
677
+ * @param {string} host - Hostname.
678
+ * @returns {Array<object>} Descriptors, or an empty list.
679
+ * @memberof UnderpostGateway
680
+ */
681
+ const readHostInstanceRegistry = ({ confDir, host }) => {
682
+ const target = hostInstanceRegistryPathFactory({ confDir, host });
683
+ if (!fs.existsSync(target)) return [];
684
+ try {
685
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
686
+ return Array.isArray(parsed) ? parsed.filter((entry) => entry?.id) : [];
687
+ } catch (error) {
688
+ logger.warn('Ignoring unreadable host instance registry', { target, message: error.message });
689
+ return [];
690
+ }
691
+ };
692
+
693
+ /**
694
+ * @method writeHostInstanceRegistry
695
+ * @description Records the descriptors a host was just rendered with.
696
+ * @param {string} confDir - Directory holding the built blocks.
697
+ * @param {string} host - Hostname.
698
+ * @param {Array<object>} instances - Descriptors used for this render.
699
+ * @returns {boolean} True when the file changed.
700
+ * @memberof UnderpostGateway
701
+ */
702
+ const writeHostInstanceRegistry = ({ confDir, host, instances = [] }) => {
703
+ const target = hostInstanceRegistryPathFactory({ confDir, host });
704
+ const next = `${JSON.stringify(instances, null, 2)}\n`;
705
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
706
+ if (current === next) return false;
707
+ fs.mkdirpSync(confDir);
708
+ fs.writeFileSync(target, next, 'utf8');
709
+ return true;
710
+ };
711
+
712
+ /**
713
+ * @method installGatewayConf
714
+ * @description Installs the built server blocks into the shared gateway and
715
+ * reloads Nginx.
716
+ *
717
+ * The blocks live in the volume rather than the ConfigMap because the workload is
718
+ * shared by every deploy: a ConfigMap would make one deploy's apply rewrite
719
+ * another's routing, and a `subPath` mount would never refresh anyway. Reloading
720
+ * signals the running master, so the config lands without dropping a connection.
721
+ *
722
+ * A block that does not parse would take the whole edge down on reload, so the
723
+ * config is validated first and the previous content is put back if it fails —
724
+ * leaving the running Nginx exactly as it was.
725
+ * @param {string} hostRoot - Node directory backing the gateway root.
726
+ * @param {string} confSourceDir - Directory holding the built blocks.
727
+ * @param {string} [namespace] - Namespace holding the workload.
728
+ * @returns {boolean} True when Nginx was reloaded with the new config.
729
+ * @throws {Error} When Nginx rejects the candidate config or cannot reload. A
730
+ * rejected candidate is restored before the error is raised.
731
+ * @memberof UnderpostGateway
732
+ */
733
+ const installGatewayConf = ({ hostRoot, confSourceDir, namespace = 'default' }) => {
734
+ if (!fs.existsSync(confSourceDir)) return false;
735
+ const blocks = fs.readdirSync(confSourceDir).filter((name) => name.endsWith('.conf'));
736
+ if (blocks.length === 0) return false;
737
+ const confDir = nodePath.join(hostRoot, UNDERPOST_GATEWAY.confDir);
738
+ // sudo: the node directory is root-owned, and the deploy may run unprivileged.
739
+ shellExec(`sudo mkdir -p ${confDir}`, { silent: true });
740
+ const previous = Object.fromEntries(
741
+ blocks.map((name) => {
742
+ const target = nodePath.join(confDir, name);
743
+ return [name, fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null];
744
+ }),
745
+ );
746
+ const restorePrevious = () => {
747
+ for (const [name, content] of Object.entries(previous)) {
748
+ const target = nodePath.join(confDir, name);
749
+ if (content === null) shellExec(`sudo rm -f ${target}`, { silent: true });
750
+ else {
751
+ const staged = nodePath.join('/tmp', `underpost-gateway-restore-${name}-${process.pid}`);
752
+ fs.writeFileSync(staged, content, 'utf8');
753
+ shellExec(`sudo cp -f ${staged} ${target}`, { silent: true });
754
+ fs.removeSync(staged);
755
+ }
756
+ }
757
+ };
758
+ for (const name of blocks)
759
+ shellExec(`sudo cp -f ${nodePath.join(confSourceDir, name)} ${nodePath.join(confDir, name)}`, { silent: true });
760
+ const test = shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -t 2>&1`, {
761
+ stdout: true,
762
+ silent: true,
763
+ silentOnError: true,
764
+ });
765
+ if (!`${test}`.includes('successful')) {
766
+ restorePrevious();
767
+ logger.error('Gateway config rejected; the previous config was restored and Nginx left running', {
768
+ blocks,
769
+ test: `${test}`.trim().split('\n').slice(-3).join(' '),
770
+ });
771
+ throw new Error(
772
+ `Gateway config rejected for ${blocks.join(', ')}: ${`${test}`.trim().split('\n').slice(-3).join(' ')}`,
773
+ );
774
+ }
775
+ try {
776
+ shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -s reload`, {
777
+ silent: true,
778
+ });
779
+ } catch (error) {
780
+ restorePrevious();
781
+ // The running master normally retains its old config when a reload signal
782
+ // fails. Re-signal after restoring so even a partial reload converges back
783
+ // to the last validated state.
784
+ shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- nginx -s reload`, {
785
+ silent: true,
786
+ silentOnError: true,
787
+ });
788
+ throw error;
789
+ }
790
+ logger.info('Gateway config installed and reloaded', { blocks, confDir });
791
+ return true;
792
+ };
793
+
794
+ /**
795
+ * @method seedDefaultStatusPage
796
+ * @description Writes the shared fallback document `nginx.conf` serves through
797
+ * `error_page`, so a host with nothing on disk gets a deliberate page instead of
798
+ * Nginx's stock error. Never overwrites an existing document — an operator may
799
+ * have replaced it.
800
+ * @param {string} hostRoot - Node directory backing the static root.
801
+ * @returns {boolean} True when the document was written.
802
+ * @memberof UnderpostGateway
803
+ */
804
+ const seedDefaultStatusPage = (hostRoot) => {
805
+ // The base config includes `<confDir>/*.conf`; the directory has to exist
806
+ // before any deploy contributes a block to it.
807
+ shellExec(`sudo mkdir -p ${nodePath.join(hostRoot, UNDERPOST_GATEWAY.confDir)}`, { silent: true });
808
+ const target = nodePath.join(hostRoot, defaultStatusPagePath(404));
809
+ if (fs.existsSync(target)) return false;
810
+ const document = `<!doctype html>
811
+ <html lang="en">
812
+ <head>
813
+ <meta charset="utf-8" />
814
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
815
+ <title>404 Not Found</title>
816
+ </head>
817
+ <body>
818
+ <h1>404</h1>
819
+ <p>The requested resource was not found.</p>
820
+ </body>
821
+ </html>
822
+ `;
823
+ shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
824
+ shellExec(`sudo tee ${target} > /dev/null <<'EOF'\n${document}EOF\n`, { silent: true });
825
+ return true;
826
+ };
827
+
828
+ /**
829
+ * @method gatewayStaticAssetExists
830
+ * @description Whether a document is present and non-empty under the gateway
831
+ * root. An empty file is treated as absent: it is what a half-finished copy
832
+ * leaves behind, and serving it would answer a status page with a blank body.
833
+ * @param {string} hostRoot - Node directory backing the gateway root.
834
+ * @param {string} assetPath - Root-relative path of the document.
835
+ * @returns {boolean} True when the document can be served.
836
+ * @memberof UnderpostGateway
837
+ */
838
+ const gatewayStaticAssetExists = ({ hostRoot, assetPath }) => {
839
+ const target = `${hostRoot}/${assetPath}`;
840
+ return fs.existsSync(target) && fs.statSync(target).size > 0;
841
+ };
842
+
843
+ /**
844
+ * @method pwaFallbackChecksFactory
845
+ * @description The fallback probes a deploy's own hosts are verified with, read
846
+ * from `conf.server.json` and `conf.ssr.json`.
847
+ *
848
+ * One probe per host/sub-path that declares a maintenance view, because that is
849
+ * the document an unreachable workload has to answer with — the condition the
850
+ * edge must satisfy before any application is deployed behind it.
851
+ *
852
+ * `loadReplicas` expansion and the `singleReplica` skip mirror
853
+ * `buildProxyRouter`/`buildManifest` exactly: a plain `replicas` path (no
854
+ * `singleReplica`) is built and routed by this same deploy id, so its fallback
855
+ * is checked too; a `singleReplica` canonical path is never built or routed
856
+ * under this deploy id at all — it is each replica's own, separate deploy id —
857
+ * so probing it here would poll a document that structurally cannot exist.
858
+ * @param {string} deployId - Deploy id whose conf declares the views.
859
+ * @returns {Array<{host: string, path: string, assetPath: string, kind: string}>} One probe per declaring sub-path.
860
+ * @memberof UnderpostGateway
861
+ */
862
+ const pwaFallbackChecksFactory = (deployId) => {
863
+ const confServer = loadReplicas(deployId, loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`));
864
+ const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
865
+ const confSSR = fs.existsSync(confSSRPath) ? JSON.parse(fs.readFileSync(confSSRPath, 'utf8')) : {};
866
+ const checks = [];
867
+ for (const host of Object.keys(confServer))
868
+ for (const path of Object.keys(confServer[host])) {
869
+ if (confServer[host][path].singleReplica) continue;
870
+ const maintenance = Underpost.deploy
871
+ .edgeRouteEntriesFactory({ confServer, confSSR, host, path })
872
+ .find((entry) => entry.context === 'maintenance');
873
+ if (maintenance)
874
+ checks.push({
875
+ host,
876
+ path,
877
+ assetPath: maintenance.assetPath,
878
+ kind: maintenance.kind,
879
+ });
880
+ }
881
+ return checks;
882
+ };
883
+
884
+ /**
885
+ * @method instanceFallbackChecksFactory
886
+ * @description The fallback probes a set of instances are verified with, one per
887
+ * host and sub-path.
888
+ *
889
+ * Deduplicated on `host + path`: a variant that declares several status pages is
890
+ * still one route to probe, and probing it once per declared status would report
891
+ * the same reachability several times.
892
+ * @param {Array<object>} [instances] - Expanded instance entries.
893
+ * @returns {Array<{host: string, path: string, assetPath: string, kind: string}>} One probe per instance sub-path.
894
+ * @memberof UnderpostGateway
895
+ */
896
+ const instanceFallbackChecksFactory = (instances = []) => {
897
+ const checks = new Map();
898
+ for (const entry of instanceStatusPageEntriesFactory({ instances }))
899
+ if (!checks.has(`${entry.host}${entry.path}`))
900
+ checks.set(`${entry.host}${entry.path}`, {
901
+ host: entry.host,
902
+ path: entry.path,
903
+ assetPath: entry.assetPath,
904
+ kind: `status:${entry.status}`,
905
+ });
906
+ return [...checks.values()];
907
+ };
908
+
909
+ /**
910
+ * @method assertStaticAssets
911
+ * @description Fails the deploy when a configured document reached neither the
912
+ * placement pass nor the gateway root.
913
+ *
914
+ * Deliberately fatal. A missing document is not visible at deploy time — the
915
+ * routes are accepted, the workload is healthy, and the gap only surfaces later
916
+ * as a shared default page in place of the host's own. Refusing to continue is
917
+ * what turns that into an immediate, attributable failure.
918
+ * @param {Array<object>} records - Placement records carrying `source` and `assetPath`.
919
+ * @param {string} hostRoot - Node directory backing the gateway root.
920
+ * @param {string} label - Workflow name used in the thrown message.
921
+ * @returns {Array<object>} The records, unchanged, when every document is present.
922
+ * @throws {Error} When any configured document is absent.
923
+ * @memberof UnderpostGateway
924
+ */
925
+ const assertStaticAssets = ({ records, hostRoot, label }) => {
926
+ const missing = records.filter(
927
+ (entry) => !entry.source && !gatewayStaticAssetExists({ hostRoot, assetPath: entry.assetPath }),
928
+ );
929
+ if (missing.length > 0)
930
+ throw new Error(
931
+ `[${label}] Static gateway bootstrap is missing configured assets: ` +
932
+ missing.map((entry) => entry.assetPath).join(', '),
933
+ );
934
+ return records;
935
+ };
936
+
937
+ /**
938
+ * @method placeInstanceStaticAssets
939
+ * @description Places every instance's declared status page in the gateway root
940
+ * and asserts the result.
941
+ *
942
+ * The documents come from the project each instance runs, so this is the only
943
+ * pass that can supply them; anything still missing afterwards would leave a
944
+ * route pointing at a document that cannot exist.
945
+ * @param {Array<object>} instances - Expanded instance entries.
946
+ * @param {object} options - Deploy/run options (gateway root, namespace).
947
+ * @param {string} label - Workflow name used in the thrown message.
948
+ * @returns {Array<object>} One record per document, with where it came from.
949
+ * @memberof UnderpostGateway
950
+ */
951
+ const placeInstanceStaticAssets = ({ instances, options, label }) => {
952
+ const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
953
+ const records = instanceStatusPageEntriesFactory({ instances }).map((entry) => ({
954
+ ...entry,
955
+ source: writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.sourcePath }) ? 'project' : null,
956
+ }));
957
+ return assertStaticAssets({ records, hostRoot, label });
958
+ };
959
+
960
+ /**
961
+ * @method gatewayFallbackProbeRunner
962
+ * @description Proves the edge answers each configured fallback with the exact
963
+ * document on disk, before any application is deployed behind it.
964
+ *
965
+ * The assertion is on the response, not on the manifests: an accepted route and a
966
+ * Programmed Gateway say nothing about which body a client receives, and every
967
+ * failure this pipeline has had was invisible in object status. The expected body
968
+ * is hashed from the file the config points at, so a probe cannot pass against a
969
+ * shared default page.
970
+ *
971
+ * With no workload deployed yet the upstream is unreachable, so the wanted status
972
+ * is an upstream failure carrying the configured document — which is the whole
973
+ * contract being verified. Polling absorbs the reconciliation window in which the
974
+ * data plane still serves the previous generation.
975
+ *
976
+ * `gatewayStatusRunner` is injected rather than imported so this module never
977
+ * depends on the runner collection that calls it.
978
+ * @param {Array<object>} checks - Probes from {@link UnderpostGateway.pwaFallbackChecksFactory} or {@link UnderpostGateway.instanceFallbackChecksFactory}.
979
+ * @param {object} options - Deploy/run options (namespace, dev, gatewayApi).
980
+ * @param {string} label - Workflow name used in log lines and thrown messages.
981
+ * @param {Function} gatewayStatusRunner - `(hosts, options) => Promise<{programmed: boolean, servesHttps: boolean}>`.
982
+ * @returns {Promise<Array<object>>} One result per probe.
983
+ * @throws {Error} When the gateway is not operational, or any probe fails.
984
+ * @memberof UnderpostGateway
985
+ */
986
+ const gatewayFallbackProbeRunner = async ({ checks, options, label, gatewayStatusRunner }) => {
987
+ if (!options.gatewayApi || checks.length === 0) return [];
988
+ const namespace = options.namespace || 'default';
989
+ shellExec(`kubectl rollout status deployment/${UNDERPOST_GATEWAY.name} -n ${namespace} --timeout=5m`);
990
+ const hosts = [...new Set(checks.map((check) => check.host))];
991
+ const gatewayStatus = await gatewayStatusRunner(hosts.join(','), { ...options, namespace });
992
+ if (!gatewayStatus.programmed || (options.dev && !gatewayStatus.servesHttps))
993
+ throw new Error(`[${label}] Gateway is not operational before application deployment`);
994
+
995
+ const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
996
+ const failures = [];
997
+ const results = [];
998
+ for (const check of checks) {
999
+ const expectedPath = `${hostRoot}/${check.assetPath}`;
1000
+ const expectedHash = gatewayStaticAssetExists({ hostRoot, assetPath: check.assetPath })
1001
+ ? crypto.createHash('sha256').update(fs.readFileSync(expectedPath)).digest('hex')
1002
+ : '';
1003
+ let body = '';
1004
+ let status = '';
1005
+ let actualHash = '';
1006
+ let passed = false;
1007
+ let attempts = 0;
1008
+ // Gateway and HTTPRoute status can still show the previous generation for a
1009
+ // short reconciliation window. Poll the actual response until the intended
1010
+ // fallback is observable instead of racing the controller once.
1011
+ for (attempts = 1; attempts <= 30; attempts++) {
1012
+ if (options.dev) {
1013
+ const url = `https://${check.host}${check.path || '/'}`;
1014
+ const curl = `curl -sSk --noproxy '*' --resolve ${check.host}:443:127.0.0.1`;
1015
+ body = shellExec(`${curl} ${url}`, { stdout: true, silent: true, silentOnError: true });
1016
+ status = shellExec(`${curl} -o /dev/null -w '%{http_code}' ${url}`, {
1017
+ stdout: true,
1018
+ silent: true,
1019
+ silentOnError: true,
1020
+ }).trim();
1021
+ } else {
1022
+ const request = `http://127.0.0.1${check.path || '/'}`;
1023
+ body = shellExec(
1024
+ `kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
1025
+ `"wget -q -O - -T 10 --header 'Host: ${check.host}' ${request} 2>/dev/null || true"`,
1026
+ { stdout: true, silent: true, silentOnError: true },
1027
+ );
1028
+ const headers = shellExec(
1029
+ `kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
1030
+ `"wget -S -O /dev/null -T 10 --header 'Host: ${check.host}' ${request} 2>&1 || true"`,
1031
+ { stdout: true, silent: true, silentOnError: true },
1032
+ );
1033
+ status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
1034
+ }
1035
+ actualHash = crypto
1036
+ .createHash('sha256')
1037
+ .update(body || '')
1038
+ .digest('hex');
1039
+ passed = /^50[234]$/.test(status) && !!expectedHash && actualHash === expectedHash;
1040
+ if (passed) break;
1041
+ if (attempts < 30) await timer(2000);
1042
+ }
1043
+ const result = {
1044
+ ...check,
1045
+ status,
1046
+ bodyMatchesConfiguredAsset: actualHash === expectedHash,
1047
+ attempts,
1048
+ passed,
1049
+ };
1050
+ results.push(result);
1051
+ if (!passed) failures.push(result);
1052
+ }
1053
+ logger.info(`[${label}] Pre-runtime fallback probes`, { results });
1054
+ if (failures.length > 0) throw new Error(`[${label}] ${failures.length}/${checks.length} fallback probes failed`);
1055
+ return results;
1056
+ };
1057
+
1058
+ export {
1059
+ UNDERPOST_GATEWAY,
1060
+ assertStaticAssets,
1061
+ gatewayFallbackProbeRunner,
1062
+ gatewayStaticAssetExists,
1063
+ hostInstanceRegistryPathFactory,
1064
+ hostServerConfFactory,
1065
+ installGatewayConf,
1066
+ instanceFallbackChecksFactory,
1067
+ placeInstanceStaticAssets,
1068
+ pwaFallbackChecksFactory,
1069
+ readHostInstanceRegistry,
1070
+ writeHostInstanceRegistry,
1071
+ kubernetesUpstreamFactory,
1072
+ underpostGatewayManifestsFactory,
1073
+ nginxConfFactory,
1074
+ seedDefaultStatusPage,
1075
+ staticLocationFactory,
1076
+ staticPathSegmentFactory,
1077
+ statusPageAssetPathFactory,
1078
+ statusPageBuildSegment,
1079
+ statusPageLocationsFactory,
1080
+ syncStaticAssetFromPod,
1081
+ writeHostServerConf,
1082
+ writeStaticAsset,
1083
+ };