underpost 3.2.70 → 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.
- package/.github/workflows/publish.ci.yml +3 -3
- package/.github/workflows/release.cd.yml +1 -1
- package/CHANGELOG.md +1358 -1038
- package/CLI-HELP.md +39 -16
- package/README.md +3 -3
- package/bin/build.js +10 -4
- package/bin/deploy.js +18 -16
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- 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 +20 -20
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/api/core/core.controller.js +4 -65
- package/src/api/core/core.router.js +8 -14
- package/src/api/default/default.controller.js +2 -70
- package/src/api/default/default.router.js +7 -17
- package/src/api/document/document.controller.js +5 -77
- package/src/api/document/document.router.js +9 -13
- package/src/api/file/file.controller.js +9 -53
- package/src/api/file/file.router.js +14 -6
- package/src/api/test/test.controller.js +8 -53
- package/src/api/test/test.router.js +1 -4
- package/src/cli/cluster.js +771 -66
- package/src/cli/db.js +6 -4
- package/src/cli/deploy.js +1715 -168
- package/src/cli/docker-compose.js +19 -24
- package/src/cli/fs.js +0 -1
- package/src/cli/image.js +40 -13
- package/src/cli/index.js +129 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/release.js +4 -0
- package/src/cli/repository.js +14 -3
- package/src/cli/run.js +2253 -439
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +38 -39
- package/src/client/components/core/Modal.js +38 -4
- package/src/client-builder/client-build.js +94 -11
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +47 -32
- package/src/index.js +1 -1
- package/src/server/conf.js +1307 -6
- package/src/server/cri.js +70 -0
- package/src/server/downloader.js +3 -3
- package/src/server/middlewares.js +152 -0
- package/src/server/underpost-gateway.js +1073 -0
- package/src/server/underpost-ingress.js +364 -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/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +469 -0
- package/test/underpost-ingress.test.js +253 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import {
|
|
6
|
+
UNDERPOST_GATEWAY,
|
|
7
|
+
hostServerConfFactory,
|
|
8
|
+
kubernetesUpstreamFactory,
|
|
9
|
+
writeHostServerConf,
|
|
10
|
+
underpostGatewayManifestsFactory,
|
|
11
|
+
nginxConfFactory,
|
|
12
|
+
staticLocationFactory,
|
|
13
|
+
staticPathSegmentFactory,
|
|
14
|
+
statusPageAssetPathFactory,
|
|
15
|
+
statusPageBuildSegment,
|
|
16
|
+
} from '../src/server/underpost-gateway.js';
|
|
17
|
+
import { staticContextRoutesFactory, statusPageRoutesFactory } from '../src/client-builder/client-build.js';
|
|
18
|
+
|
|
19
|
+
// A `conf.ssr.json` client entry: two intercepted contexts, one status page,
|
|
20
|
+
// and one ordinary view that must stay with the workload.
|
|
21
|
+
const VIEWS = [
|
|
22
|
+
{ path: '/offline', title: 'No Network Connection', client: 'NoNetworkConnection', offlineDefault: true },
|
|
23
|
+
{ path: '/maintenance', title: 'Server Maintenance', client: 'Maintenance', maintenanceDefault: true },
|
|
24
|
+
{ path: '/test', title: 'Test', client: 'Test' },
|
|
25
|
+
{ path: '/404', title: '404 Instance Not Found', client: 'Cyberia404' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const manifests = (overrides = {}) =>
|
|
29
|
+
underpostGatewayManifestsFactory({
|
|
30
|
+
namespace: 'default',
|
|
31
|
+
hostPath: `/home/dd/engine/volume/${UNDERPOST_GATEWAY.volumeName}`,
|
|
32
|
+
nodeName: 'node-a',
|
|
33
|
+
...overrides,
|
|
34
|
+
})
|
|
35
|
+
.split('\n---\n')
|
|
36
|
+
.filter((doc) => doc.trim());
|
|
37
|
+
|
|
38
|
+
const kind = (docs, name) => docs.find((doc) => doc.includes(`\nkind: ${name}\n`));
|
|
39
|
+
|
|
40
|
+
// Undo the block-scalar indent the ConfigMap wraps the config in, which is the
|
|
41
|
+
// step that would corrupt it. The trailing newline is the document separator's,
|
|
42
|
+
// stripped on the way in and restored here.
|
|
43
|
+
const CONFIG_MAP_KEY = ' nginx.conf: |\n';
|
|
44
|
+
const configMapNginxConf = (docs) => {
|
|
45
|
+
const configMap = kind(docs, 'ConfigMap');
|
|
46
|
+
return `${configMap
|
|
47
|
+
.slice(configMap.indexOf(CONFIG_MAP_KEY) + CONFIG_MAP_KEY.length)
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((line) => (line.length > 0 ? line.slice(4) : line))
|
|
50
|
+
.join('\n')}\n`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
describe('underpost gateway edge tier', () => {
|
|
54
|
+
describe('view selection', () => {
|
|
55
|
+
it('routes status pages and intercepted contexts to the edge, and nothing else', () => {
|
|
56
|
+
expect(statusPageRoutesFactory({ views: VIEWS, proxyPath: '/' }).map((route) => route.status)).to.deep.equal([
|
|
57
|
+
'404',
|
|
58
|
+
]);
|
|
59
|
+
expect(staticContextRoutesFactory({ views: VIEWS, proxyPath: '/' }).map((route) => route.context)).to.deep.equal([
|
|
60
|
+
'offline',
|
|
61
|
+
'maintenance',
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('scopes both kinds to the instance sub-path', () => {
|
|
66
|
+
expect(statusPageRoutesFactory({ views: VIEWS, proxyPath: '/FOREST' })[0].routePath).to.equal('/FOREST/404');
|
|
67
|
+
expect(staticContextRoutesFactory({ views: VIEWS, proxyPath: '/FOREST' })[0].routePath).to.equal(
|
|
68
|
+
'/FOREST/offline',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('document layout', () => {
|
|
74
|
+
it('folds the root sub-path into a directory of its own', () => {
|
|
75
|
+
expect(staticPathSegmentFactory('/')).to.equal('root');
|
|
76
|
+
expect(staticPathSegmentFactory('/FOREST')).to.equal('FOREST');
|
|
77
|
+
expect(staticPathSegmentFactory('/a/b')).to.equal('a-b');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps each document in a directory, so a prefix rewrite covers what sits beside it', () => {
|
|
81
|
+
const location = statusPageAssetPathFactory({ host: 'www.cyberiaonline.com', path: '/', status: 404 });
|
|
82
|
+
expect(location.assetPath).to.equal('www.cyberiaonline.com/root/status-pages/404/index.html');
|
|
83
|
+
expect(location.dir).to.equal('/www.cyberiaonline.com/root/status-pages/404');
|
|
84
|
+
expect(location.url).to.equal('/www.cyberiaonline.com/root/status-pages/404/index.html');
|
|
85
|
+
expect(location.assetPath).to.equal(`${location.dir}/index.html`.slice(1));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('separates instances of the same host', () => {
|
|
89
|
+
const forest = statusPageAssetPathFactory({ host: 'client.cyberiaonline.com', path: '/FOREST', status: 404 });
|
|
90
|
+
const root = statusPageAssetPathFactory({ host: 'client.cyberiaonline.com', path: '/', status: 404 });
|
|
91
|
+
expect(forest.assetPath).to.equal('client.cyberiaonline.com/FOREST/status-pages/404/index.html');
|
|
92
|
+
expect(forest.assetPath).to.not.equal(root.assetPath);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('gives contexts the same shape as status pages', () => {
|
|
96
|
+
expect(staticLocationFactory({ host: 'underpost.net', path: '/', context: 'offline' }).assetPath).to.equal(
|
|
97
|
+
'underpost.net/root/offline/index.html',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('nginx config', () => {
|
|
103
|
+
const conf = nginxConfFactory();
|
|
104
|
+
|
|
105
|
+
it('resolves a prefix rewrite onto a directory through its index', () => {
|
|
106
|
+
expect(conf).to.include('try_files $uri $uri/index.html =404;');
|
|
107
|
+
expect(conf).to.include(`root ${UNDERPOST_GATEWAY.root};`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Every upstream is dialled through a variable so a redeployed Service is
|
|
111
|
+
// re-resolved; that requires a resolver, and nginx cannot resolve its own.
|
|
112
|
+
it('carries a literal resolver address and includes the per-host blocks', () => {
|
|
113
|
+
expect(conf).to.match(/resolver \d+\.\d+\.\d+\.\d+ valid=/);
|
|
114
|
+
expect(conf).to.include(`include ${UNDERPOST_GATEWAY.root}/${UNDERPOST_GATEWAY.confDir}/*.conf;`);
|
|
115
|
+
expect(nginxConfFactory({ resolver: '10.0.0.10' })).to.include('resolver 10.0.0.10 valid=');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('defines the websocket upgrade map the host blocks reference', () => {
|
|
119
|
+
expect(conf).to.include('map $http_upgrade $connection_upgrade');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// A 200 would let the PWA service worker store the shared page as the
|
|
123
|
+
// host's own and keep serving it after the real document lands.
|
|
124
|
+
it('serves the shared fallback as a 404 that is never stored', () => {
|
|
125
|
+
expect(conf).to.include(`error_page 404 /${UNDERPOST_GATEWAY.defaultHostDir}/status-pages/404/index.html;`);
|
|
126
|
+
expect(conf).to.match(/location = \/default\/status-pages\/404\/index\.html \{\s*\n\s*internal;/);
|
|
127
|
+
expect(conf).to.include("add_header Cache-Control 'no-store' always;");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Interception is the whole reason Nginx sits in the request path: the status
|
|
132
|
+
// code and the client's URI survive, and the document is read from disk so its
|
|
133
|
+
// size is unbounded — none of which an inline Envoy body can do.
|
|
134
|
+
describe('host server blocks', () => {
|
|
135
|
+
const conf = () =>
|
|
136
|
+
hostServerConfFactory({
|
|
137
|
+
host: 'server.fixture.test',
|
|
138
|
+
routes: [
|
|
139
|
+
{ path: '/', upstream: 'root-service:8083', statuses: { 404: 'status-pages/404' } },
|
|
140
|
+
{
|
|
141
|
+
path: '/FOREST',
|
|
142
|
+
upstream: 'forest-service:8083',
|
|
143
|
+
statuses: { 404: 'status-pages/404', 503: 'maintenance' },
|
|
144
|
+
stripPrefix: true,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('intercepts each sub-path onto its own document', () => {
|
|
150
|
+
expect(conf()).to.include('proxy_intercept_errors on;');
|
|
151
|
+
expect(conf()).to.include('error_page 404 @status_FOREST_404;');
|
|
152
|
+
expect(conf()).to.include('try_files /server.fixture.test/FOREST/status-pages/404/index.html =404;');
|
|
153
|
+
expect(conf()).to.include('try_files /server.fixture.test/root/status-pages/404/index.html =404;');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('qualifies short Service names for the runtime DNS resolver', () => {
|
|
157
|
+
expect(conf()).to.include('set $upstream_root root-service.default.svc.cluster.local:8083;');
|
|
158
|
+
expect(conf()).to.include('set $upstream_FOREST forest-service.default.svc.cluster.local:8083;');
|
|
159
|
+
expect(kubernetesUpstreamFactory('svc:80', 'games')).to.equal('svc.games.svc.cluster.local:80');
|
|
160
|
+
expect(kubernetesUpstreamFactory('svc.other.svc.cluster.local:80', 'games')).to.equal(
|
|
161
|
+
'svc.other.svc.cluster.local:80',
|
|
162
|
+
);
|
|
163
|
+
expect(kubernetesUpstreamFactory('10.0.0.8:80', 'games')).to.equal('10.0.0.8:80');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// The rewritten document path arrives at this same server block. Without a
|
|
167
|
+
// location for it, `location /` proxies it to the application, which has no
|
|
168
|
+
// such route — and an application that redirects its own 404s then bounces
|
|
169
|
+
// between the route and the rewrite until the browser gives up.
|
|
170
|
+
it("serves the host's own documents from disk instead of proxying them", () => {
|
|
171
|
+
expect(conf()).to.include('location /server.fixture.test/ {');
|
|
172
|
+
const documentLocation = conf().slice(conf().indexOf('location /server.fixture.test/ {'));
|
|
173
|
+
expect(documentLocation.slice(0, documentLocation.indexOf('}'))).to.include(
|
|
174
|
+
'try_files $uri $uri/index.html =404;',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Longer prefix than the proxied root, so nginx prefers it.
|
|
179
|
+
it('places the document location ahead of the proxy', () => {
|
|
180
|
+
expect(conf().indexOf('location /server.fixture.test/ {')).to.be.lessThan(conf().indexOf('location / {'));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// A dead workload is what a maintenance page is for.
|
|
184
|
+
it('answers upstream failure from the maintenance context', () => {
|
|
185
|
+
expect(conf()).to.include('error_page 503 @status_FOREST_503;');
|
|
186
|
+
expect(conf()).to.include('try_files /server.fixture.test/FOREST/maintenance/index.html =503;');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// `error_page 404 @x` keeps the upstream's status; `error_page 404 = @x`
|
|
190
|
+
// would replace it with the status of the page itself.
|
|
191
|
+
it('never rewrites the status it intercepted', () => {
|
|
192
|
+
expect(conf()).to.not.match(/error_page \d+ = /);
|
|
193
|
+
expect(conf()).to.not.include('return 30');
|
|
194
|
+
expect(conf()).to.not.include('location.replace');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('strips a variant prefix only where the instance asks for it', () => {
|
|
198
|
+
expect(conf()).to.include('rewrite ^/FOREST/?(.*)$ /$1 break;');
|
|
199
|
+
expect(conf()).to.not.include('rewrite ^//?');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// nginx variable names admit only word characters.
|
|
203
|
+
it('emits identifiers nginx accepts for a multi-segment path', () => {
|
|
204
|
+
const nested = hostServerConfFactory({
|
|
205
|
+
host: 'h.test',
|
|
206
|
+
routes: [{ path: '/a/b', upstream: 'svc:80', statuses: { 404: 'status-pages/404' } }],
|
|
207
|
+
});
|
|
208
|
+
expect(nested).to.include('set $upstream_a_b svc.default.svc.cluster.local:80;');
|
|
209
|
+
expect(nested).to.not.match(/\$upstream_\S*-/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// The map lives in the base config; the header that uses it lives here.
|
|
213
|
+
it('forwards websocket upgrades across the proxied hop', () => {
|
|
214
|
+
expect(conf()).to.include('proxy_set_header Upgrade $http_upgrade;');
|
|
215
|
+
expect(conf()).to.include('proxy_set_header Connection $connection_upgrade;');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('proxies without interception when nothing is declared', () => {
|
|
219
|
+
const bare = hostServerConfFactory({ host: 'h.test', routes: [{ path: '/', upstream: 'svc:80' }] });
|
|
220
|
+
expect(bare).to.include('proxy_intercept_errors off;');
|
|
221
|
+
expect(bare).to.not.include('error_page');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('renders nothing for a host that proxies nothing', () => {
|
|
225
|
+
expect(hostServerConfFactory({ host: 'h.test', routes: [] })).to.equal('');
|
|
226
|
+
expect(hostServerConfFactory({ host: 'h.test', routes: [{ path: '/' }] })).to.equal('');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('workload manifests', () => {
|
|
231
|
+
it('renders the whole workload as one document set', () => {
|
|
232
|
+
expect(manifests().map((doc) => /\nkind: (\w+)\n/.exec(doc)[1])).to.deep.equal([
|
|
233
|
+
'ConfigMap',
|
|
234
|
+
'PersistentVolume',
|
|
235
|
+
'PersistentVolumeClaim',
|
|
236
|
+
'Deployment',
|
|
237
|
+
'Service',
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('carries the rendered nginx.conf verbatim', () => {
|
|
242
|
+
expect(configMapNginxConf(manifests())).to.equal(nginxConfFactory());
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// The config is mounted with `subPath`, which Kubernetes never refreshes in
|
|
246
|
+
// place: without a pod-template change an edited nginx.conf reaches the
|
|
247
|
+
// ConfigMap and nothing else.
|
|
248
|
+
it('rolls the pod when the config changes, and only then', () => {
|
|
249
|
+
const hash = (docs) => /underpost\.net\/nginx-conf-hash: '(\w+)'/.exec(kind(docs, 'Deployment'))[1];
|
|
250
|
+
expect(hash(manifests())).to.have.length(16);
|
|
251
|
+
expect(hash(manifests())).to.equal(hash(manifests({ storage: '2Gi' })));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('pins the volume to the node holding the documents', () => {
|
|
255
|
+
const pv = kind(manifests(), 'PersistentVolume');
|
|
256
|
+
expect(pv).to.include(`path: /home/dd/engine/volume/${UNDERPOST_GATEWAY.volumeName}`);
|
|
257
|
+
expect(pv).to.include('- node-a');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('omits node affinity when no node is resolved', () => {
|
|
261
|
+
expect(kind(manifests({ nodeName: '' }), 'PersistentVolume')).to.not.include('nodeAffinity');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// A backtick anywhere inside these templates closes the literal early and the
|
|
266
|
+
// rest is evaluated as JavaScript, so the factory silently returns a number.
|
|
267
|
+
describe('config template integrity', () => {
|
|
268
|
+
it('renders a string, not an expression', () => {
|
|
269
|
+
expect(nginxConfFactory()).to.be.a('string').with.length.greaterThan(0);
|
|
270
|
+
expect(
|
|
271
|
+
hostServerConfFactory({
|
|
272
|
+
host: 'h.test',
|
|
273
|
+
routes: [{ path: '/', upstream: 's:80', statuses: { 404: 'status-pages/404' } }],
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
.to.be.a('string')
|
|
277
|
+
.with.length.greaterThan(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// The client's URI must survive a status page being served. Interception is the
|
|
282
|
+
// only delivery path that keeps it: a route for `/404` would make the page a
|
|
283
|
+
// destination, and every hop to a destination is a URI the client did not ask
|
|
284
|
+
// for. Contexts are different — `/offline` is an address a client requests and
|
|
285
|
+
// the service worker precaches by URL.
|
|
286
|
+
describe('status delivery preserves the URI', () => {
|
|
287
|
+
const statusView = (path) => /^\/([1-5]\d{2})$/.test(path);
|
|
288
|
+
|
|
289
|
+
it('separates status pages from routable contexts', () => {
|
|
290
|
+
const views = [
|
|
291
|
+
{ path: '/404', client: 'S404' },
|
|
292
|
+
{ path: '/offline', client: 'Off', offlineDefault: true },
|
|
293
|
+
{ path: '/maintenance', client: 'Mnt', maintenanceDefault: true },
|
|
294
|
+
];
|
|
295
|
+
expect(statusPageRoutesFactory({ views, proxyPath: '/' }).map((r) => r.routePath)).to.deep.equal(['/404']);
|
|
296
|
+
expect(staticContextRoutesFactory({ views, proxyPath: '/' }).map((r) => r.routePath)).to.deep.equal([
|
|
297
|
+
'/offline',
|
|
298
|
+
'/maintenance',
|
|
299
|
+
]);
|
|
300
|
+
// Only the contexts are emitted as routes; the status page is not.
|
|
301
|
+
expect(views.filter((v) => statusView(v.path)).length).to.equal(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Every reachable path in the block is either the proxy or an internal
|
|
305
|
+
// interception target — never an outward-facing status page URL.
|
|
306
|
+
it('exposes no status page as a request target', () => {
|
|
307
|
+
const conf = hostServerConfFactory({
|
|
308
|
+
host: 'h.test',
|
|
309
|
+
routes: [{ path: '/', upstream: 's:80', statuses: { 404: 'status-pages/404' } }],
|
|
310
|
+
});
|
|
311
|
+
expect(conf).to.not.match(/location\s+\/404\b/);
|
|
312
|
+
expect(conf).to.include('error_page 404 @status_root_404;');
|
|
313
|
+
// Named locations are unreachable from outside by construction.
|
|
314
|
+
expect(conf).to.include('location @status_root_404 {');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// The runtime redirects its own 404 only when it has a page on that route to
|
|
319
|
+
// redirect to. Building the status page off `/<status>` removes the route, so a
|
|
320
|
+
// runtime that predates the agnostic change still answers a bare 404 — which is
|
|
321
|
+
// what the gateway intercepts, keeping the client's URI.
|
|
322
|
+
describe('status pages are not runtime routes', () => {
|
|
323
|
+
it('builds under status-pages, never on the status route', () => {
|
|
324
|
+
expect(statusPageBuildSegment(404)).to.equal('status-pages/404/index.html');
|
|
325
|
+
expect(statusPageBuildSegment(404)).to.not.equal('404/index.html');
|
|
326
|
+
expect(statusPageBuildSegment('503')).to.equal('status-pages/503/index.html');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// The document the gateway serves and the one the build writes are named by
|
|
330
|
+
// the same convention, so the sync cannot look where nothing was written.
|
|
331
|
+
it('shares the status-pages name with the gateway layout', () => {
|
|
332
|
+
const served = statusPageAssetPathFactory({ host: 'h.test', path: '/', status: 404 }).assetPath;
|
|
333
|
+
expect(served).to.include('status-pages/404/index.html');
|
|
334
|
+
expect(statusPageBuildSegment(404)).to.equal('status-pages/404/index.html');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// A build must work with no cluster running: generating manifests cannot depend
|
|
339
|
+
// on a running gateway to validate against, and cannot mutate the host either.
|
|
340
|
+
// Installing and reloading is the apply path's job.
|
|
341
|
+
describe('build/apply separation', () => {
|
|
342
|
+
const source = fs.readFileSync(new URL('../src/server/underpost-gateway.js', import.meta.url), 'utf8');
|
|
343
|
+
const bodyOf = (name) => {
|
|
344
|
+
const start = source.indexOf(`const ${name} = `);
|
|
345
|
+
const next = source.slice(start + 1).search(/\nconst \w+ = |\nexport \{/);
|
|
346
|
+
return source.slice(start, start + 1 + next);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
it('renders and writes without touching the cluster', () => {
|
|
350
|
+
for (const name of [
|
|
351
|
+
'hostServerConfFactory',
|
|
352
|
+
'nginxConfFactory',
|
|
353
|
+
'writeHostServerConf',
|
|
354
|
+
'statusPageLocationsFactory',
|
|
355
|
+
]) {
|
|
356
|
+
expect(bodyOf(name)).to.not.include('kubectl');
|
|
357
|
+
expect(bodyOf(name)).to.not.include('sudo ');
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('keeps the cluster work in the install path', () => {
|
|
362
|
+
expect(bodyOf('installGatewayConf')).to.include('nginx -t');
|
|
363
|
+
expect(bodyOf('installGatewayConf')).to.include('nginx -s reload');
|
|
364
|
+
expect(bodyOf('installGatewayConf')).to.include('throw new Error');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('writes a block to the directory it is given, and removes it when empty', () => {
|
|
368
|
+
const dir = fs.mkdtempSync('/tmp/underpost-gateway-test-');
|
|
369
|
+
try {
|
|
370
|
+
expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: 'server {}\n' })).to.equal(true);
|
|
371
|
+
expect(fs.readFileSync(`${dir}/h.test.conf`, 'utf8')).to.equal('server {}\n');
|
|
372
|
+
// Idempotent: an unchanged block is not rewritten.
|
|
373
|
+
expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: 'server {}\n' })).to.equal(false);
|
|
374
|
+
expect(writeHostServerConf({ confDir: dir, host: 'h.test', conf: '' })).to.equal(true);
|
|
375
|
+
expect(fs.existsSync(`${dir}/h.test.conf`)).to.equal(false);
|
|
376
|
+
} finally {
|
|
377
|
+
fs.removeSync(dir);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// The manifests are piped to `kubectl apply -f -` through a heredoc. Every
|
|
383
|
+
// value is already substituted by the template literal, so anything the shell
|
|
384
|
+
// would expand is content — and `nginx.conf` is nothing but content the shell
|
|
385
|
+
// recognises. An unquoted delimiter turns `try_files $uri $uri/index.html`
|
|
386
|
+
// into `try_files /index.html`, which matches nothing, and every host's
|
|
387
|
+
// status page is answered by the shared default instead.
|
|
388
|
+
describe('shell safety', () => {
|
|
389
|
+
const applySites = () =>
|
|
390
|
+
['src/cli/cluster.js', 'src/cli/deploy.js', 'src/cli/run.js'].flatMap((file) =>
|
|
391
|
+
fs
|
|
392
|
+
.readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
|
393
|
+
.split('\n')
|
|
394
|
+
.map((line, index) => ({ file, line: index + 1, text: line }))
|
|
395
|
+
.filter((entry) => entry.text.includes('kubectl apply') && entry.text.includes('<<')),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
it('applies every generated manifest through a quoted heredoc', () => {
|
|
399
|
+
const unquoted = applySites().filter((entry) => !entry.text.includes("<<'EOF'"));
|
|
400
|
+
expect(unquoted.map((entry) => `${entry.file}:${entry.line}`)).to.deep.equal([]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('renders nginx variables the shell would otherwise eat', () => {
|
|
404
|
+
expect(nginxConfFactory()).to.include('try_files $uri $uri/index.html =404;');
|
|
405
|
+
expect(nginxConfFactory()).to.match(/\$remote_addr.+\$request.+\$status/);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe('traffic switch publication order', () => {
|
|
410
|
+
const deploySource = fs.readFileSync(new URL('../src/cli/deploy.js', import.meta.url), 'utf8');
|
|
411
|
+
const switchTraffic = deploySource.slice(
|
|
412
|
+
deploySource.indexOf(' switchTraffic('),
|
|
413
|
+
deploySource.indexOf(' resolveDeployNode(', deploySource.indexOf(' switchTraffic(')),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
it('loads the rebuilt gateway host blocks before applying HTTPRoutes', () => {
|
|
417
|
+
const install = switchTraffic.indexOf('installGatewayConf({');
|
|
418
|
+
const apply = switchTraffic.indexOf("for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml']");
|
|
419
|
+
expect(install).to.be.greaterThan(-1);
|
|
420
|
+
expect(apply).to.be.greaterThan(install);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('refreshes the shared ingress host table after publishing routes', () => {
|
|
424
|
+
const apply = switchTraffic.indexOf('shellExec(`sudo kubectl apply -f ${buildPath}/${file}');
|
|
425
|
+
const refresh = switchTraffic.indexOf('Underpost.cluster.refreshUnderpostIngress({ namespace, options });');
|
|
426
|
+
expect(apply).to.be.greaterThan(-1);
|
|
427
|
+
expect(refresh).to.be.greaterThan(apply);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('keeps the stable selector on the live colour until route migration completes', () => {
|
|
431
|
+
const ready = switchTraffic.indexOf('!Underpost.deploy.awaitServiceEndpoints({');
|
|
432
|
+
const bootstrap = switchTraffic.indexOf('Underpost.deploy.applyTrafficService({', ready);
|
|
433
|
+
const apply = switchTraffic.indexOf("for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml']");
|
|
434
|
+
const removeOldRoute = switchTraffic.indexOf('Underpost.deploy.removeInactiveHostRoutes({', apply);
|
|
435
|
+
const targetSelector = switchTraffic.indexOf('if (targetTraffic !== bootstrapTraffic)', removeOldRoute);
|
|
436
|
+
expect(ready).to.be.greaterThan(-1);
|
|
437
|
+
expect(bootstrap).to.be.greaterThan(ready);
|
|
438
|
+
expect(apply).to.be.greaterThan(bootstrap);
|
|
439
|
+
expect(removeOldRoute).to.be.greaterThan(apply);
|
|
440
|
+
expect(targetSelector).to.be.greaterThan(removeOldRoute);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('merged Gateway listener isolation', () => {
|
|
445
|
+
const deploySource = fs.readFileSync(new URL('../src/cli/deploy.js', import.meta.url), 'utf8');
|
|
446
|
+
const gatewayFactory = deploySource.slice(
|
|
447
|
+
deploySource.indexOf(' gatewayYamlFactory('),
|
|
448
|
+
deploySource.indexOf(' gatewayNameFactory(', deploySource.indexOf(' gatewayYamlFactory(')),
|
|
449
|
+
);
|
|
450
|
+
const policyFactory = deploySource.slice(
|
|
451
|
+
deploySource.indexOf(' clientTrafficPolicyYamlFactory('),
|
|
452
|
+
deploySource.indexOf(' httpRouteRuleFactory(', deploySource.indexOf(' clientTrafficPolicyYamlFactory(')),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
it('gives every merged HTTP and HTTPS listener an explicit hostname', () => {
|
|
456
|
+
expect(gatewayFactory).to.include('hostname: ${JSON.stringify(host)}');
|
|
457
|
+
expect(gatewayFactory).to.include("gatewayListenerNameFactory({ protocol: 'http', host })");
|
|
458
|
+
expect(gatewayFactory).to.include("gatewayListenerNameFactory({ protocol: 'https', host })");
|
|
459
|
+
expect(gatewayFactory).not.to.include(' - name: http\n');
|
|
460
|
+
expect(gatewayFactory).not.to.include(' - name: https\n');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('targets HTTP/3 at every distinct HTTPS listener from one policy', () => {
|
|
464
|
+
expect(policyFactory).to.include('const targets = [...new Set([...sectionNames, sectionName].filter(Boolean))]');
|
|
465
|
+
expect(policyFactory).to.include('sectionName: ${target}');
|
|
466
|
+
expect(policyFactory).to.include(".join('\\n')");
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|