underpost 3.2.80 → 3.2.90

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