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.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +1216 -168
- package/src/server/cri.js +70 -0
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- 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
|
+
};
|