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,253 @@
1
+ 'use strict';
2
+
3
+ import { expect } from 'chai';
4
+ import fs from 'fs-extra';
5
+ // Named import: js-yaml's ESM build exports no default.
6
+ import { loadAll } from 'js-yaml';
7
+ import {
8
+ UNDERPOST_INGRESS,
9
+ gatewayBackendFactory,
10
+ underpostIngressConfFactory,
11
+ underpostIngressHostMapFactory,
12
+ underpostIngressManifestsFactory,
13
+ } from '../src/server/underpost-ingress.js';
14
+
15
+ const CONTOUR = UNDERPOST_INGRESS.backends.contour;
16
+ const GATEWAY = gatewayBackendFactory('envoy-eg-d8c59e83');
17
+ const BOTH = { contour: CONTOUR, gateway: GATEWAY };
18
+
19
+ describe('shared ingress front', () => {
20
+ describe('underpostIngressHostMapFactory', () => {
21
+ it('routes each hostname to the stack that describes it', () => {
22
+ const { entries } = underpostIngressHostMapFactory({
23
+ contourHosts: ['legacy.test'],
24
+ gatewayHosts: ['app.test'],
25
+ });
26
+ expect(entries).to.deep.equal([
27
+ { host: 'app.test', backend: 'gateway' },
28
+ { host: 'legacy.test', backend: 'contour' },
29
+ ]);
30
+ });
31
+
32
+ // Both objects existing for one hostname is what switching stacks leaves
33
+ // behind. Only one can win, so the choice is fixed rather than left to
34
+ // whichever happened to be listed first.
35
+ it('reports a hostname described by both, and resolves it to the preferred stack', () => {
36
+ const { entries, conflicts } = underpostIngressHostMapFactory({
37
+ contourHosts: ['shared.test'],
38
+ gatewayHosts: ['shared.test'],
39
+ });
40
+ expect(conflicts).to.deep.equal(['shared.test']);
41
+ expect(entries).to.deep.equal([{ host: 'shared.test', backend: 'gateway' }]);
42
+ });
43
+
44
+ it('honours an explicit preference for the conflicted hostname', () => {
45
+ const { entries } = underpostIngressHostMapFactory({
46
+ contourHosts: ['shared.test'],
47
+ gatewayHosts: ['shared.test'],
48
+ preferred: 'contour',
49
+ });
50
+ expect(entries).to.deep.equal([{ host: 'shared.test', backend: 'contour' }]);
51
+ });
52
+
53
+ it('deduplicates and drops empty hostnames', () => {
54
+ const { entries, conflicts } = underpostIngressHostMapFactory({
55
+ contourHosts: ['a.test', 'a.test', '', null],
56
+ gatewayHosts: [],
57
+ });
58
+ expect(entries).to.deep.equal([{ host: 'a.test', backend: 'contour' }]);
59
+ expect(conflicts).to.deep.equal([]);
60
+ });
61
+
62
+ it('is empty with nothing routed', () => {
63
+ expect(underpostIngressHostMapFactory()).to.deep.equal({ entries: [], conflicts: [] });
64
+ });
65
+ });
66
+
67
+ describe('underpostIngressConfFactory', () => {
68
+ const conf = (input) => underpostIngressConfFactory({ backends: BOTH, ...input });
69
+
70
+ it('maps a hostname to its stack in both the HTTP and the TLS table', () => {
71
+ const out = conf({ entries: [{ host: 'legacy.test', backend: 'contour' }] });
72
+ expect(out).to.include(`legacy.test ${CONTOUR.http};`);
73
+ expect(out).to.include(`legacy.test ${CONTOUR.tls};`);
74
+ });
75
+
76
+ // The upstream must reach nginx as a variable. A literal upstream in a
77
+ // `stream` block is resolved when the config is parsed, so nginx refuses to
78
+ // start until the Service has DNS and then caches that address forever.
79
+ it('passes every upstream through a variable so the resolver applies', () => {
80
+ const out = conf({ entries: [] });
81
+ expect(out).to.include('proxy_pass http://$underpost_ingress_http_upstream;');
82
+ expect(out).to.include('proxy_pass $underpost_ingress_tls_upstream;');
83
+ expect(out).to.include('proxy_pass $underpost_ingress_quic_upstream;');
84
+ expect(out).to.not.match(/proxy_pass\s+[a-z0-9.-]+\.svc\.cluster\.local/);
85
+ });
86
+
87
+ it('falls back to the Gateway API stack for an unknown hostname', () => {
88
+ const out = conf({ entries: [] });
89
+ expect(out).to.include(`default ${GATEWAY.http};`);
90
+ expect(out).to.include(`default ${GATEWAY.tls};`);
91
+ });
92
+
93
+ it('falls back to the only stack installed', () => {
94
+ const out = underpostIngressConfFactory({ backends: { contour: CONTOUR }, defaultBackend: 'gateway' });
95
+ expect(out).to.include(`default ${CONTOUR.http};`);
96
+ });
97
+
98
+ // A host left over from the other stack would otherwise render a map entry
99
+ // pointing at an upstream that does not exist in this config.
100
+ it('drops a hostname whose stack is not installed', () => {
101
+ const out = underpostIngressConfFactory({
102
+ backends: { gateway: GATEWAY },
103
+ entries: [
104
+ { host: 'orphan.test', backend: 'contour' },
105
+ { host: 'app.test', backend: 'gateway' },
106
+ ],
107
+ });
108
+ expect(out).to.not.include('orphan.test');
109
+ expect(out).to.include('app.test');
110
+ expect(out).to.not.include('projectcontour');
111
+ });
112
+
113
+ it('serves QUIC only when the Gateway API data plane is present', () => {
114
+ expect(conf({ entries: [] })).to.include(`listen ${UNDERPOST_INGRESS.httpsPort} udp;`);
115
+ expect(underpostIngressConfFactory({ backends: { contour: CONTOUR } })).to.not.include('udp;');
116
+ });
117
+
118
+ it('refuses to render with no backend at all', () => {
119
+ expect(() => underpostIngressConfFactory({ backends: {} })).to.throw(/No data plane backend/);
120
+ });
121
+
122
+ it('carries the cluster DNS resolver into both the http and stream contexts', () => {
123
+ const out = conf({ entries: [], resolver: '10.43.0.10' });
124
+ expect(out.match(/resolver 10\.43\.0\.10 valid=10s ipv6=off;/g)).to.have.lengthOf(2);
125
+ });
126
+ });
127
+
128
+ describe('underpostIngressManifestsFactory', () => {
129
+ // The regression that left the edge Pending: under `hostNetwork: true`
130
+ // Kubernetes sets `hostPort` to each declared `containerPort`, and the
131
+ // scheduler then refuses to place the pod until those host ports are free —
132
+ // which they only become once this pod replaces the data planes holding them.
133
+ it('takes the host network while declaring no port field at all', () => {
134
+ const out = underpostIngressManifestsFactory({ conf: 'worker_processes auto;' });
135
+ expect(out).to.include('hostNetwork: true');
136
+ expect(out).to.include('dnsPolicy: ClusterFirstWithHostNet');
137
+ const fields = out.split('\n').filter((line) => !line.trim().startsWith('#'));
138
+ expect(fields.filter((line) => /^\s*-?\s*(ports|containerPort|hostPort):/.test(line))).to.deep.equal([]);
139
+ });
140
+
141
+ it('restarts from the cached Nginx image when an edge node cannot reach Docker Hub', () => {
142
+ expect(underpostIngressManifestsFactory({ conf: 'x' })).to.include('imagePullPolicy: IfNotPresent');
143
+ });
144
+
145
+ // Host-map changes are reloaded in place. Keeping them out of the pod
146
+ // template avoids a Recreate gap on every HTTPRoute/HTTPProxy migration.
147
+ it('keeps the pod template stable and runs from a writable config copy', () => {
148
+ const deployment = (conf) =>
149
+ loadAll(underpostIngressManifestsFactory({ conf })).find((document) => document?.kind === 'Deployment');
150
+ expect(deployment('a').spec.template).to.deep.equal(deployment('b').spec.template);
151
+ const out = underpostIngressManifestsFactory({ conf: 'a' });
152
+ expect(out).to.include('cp /etc/underpost-ingress/nginx.conf /tmp/nginx.conf');
153
+ expect(out).to.not.include('subPath:');
154
+ });
155
+
156
+ // Verified against the real nginx:alpine image: dropping any of these is a
157
+ // start-up failure, and only NET_BIND_SERVICE is visible to `nginx -t` —
158
+ // CHOWN fails on the temp paths, SETUID/SETGID when workers spawn.
159
+ it('grants every capability the nginx master needs to start', () => {
160
+ const out = underpostIngressManifestsFactory({ conf: 'x' });
161
+ for (const capability of ['NET_BIND_SERVICE', 'CHOWN', 'SETUID', 'SETGID'])
162
+ expect(out).to.include(`- ${capability}`);
163
+ expect(out).to.include('- ALL');
164
+ });
165
+
166
+ // The node's ports are a single resource: a rolling update would surge a
167
+ // second pod that cannot bind and stall the rollout indefinitely.
168
+ it('replaces rather than rolls, because the ports cannot be held twice', () => {
169
+ expect(underpostIngressManifestsFactory({ conf: 'x' })).to.include('type: Recreate');
170
+ });
171
+
172
+ it('pins to a node only when one is given', () => {
173
+ expect(underpostIngressManifestsFactory({ conf: 'x', nodeName: 'node-1' })).to.include(
174
+ 'kubernetes.io/hostname: node-1',
175
+ );
176
+ expect(underpostIngressManifestsFactory({ conf: 'x' })).to.not.include('nodeSelector');
177
+ });
178
+
179
+ // The manifest is assembled by string templating, and the config is embedded
180
+ // as an indented block scalar — so "it looks right" is not evidence. Parsing
181
+ // it back is what proves the pod spec is the one intended and that the
182
+ // embedded config survived the indentation intact.
183
+ it('parses as YAML, with the config round-tripping unchanged', () => {
184
+ const conf = underpostIngressConfFactory({
185
+ entries: [{ host: 'legacy.test', backend: 'contour' }],
186
+ backends: BOTH,
187
+ });
188
+ const documents = loadAll(underpostIngressManifestsFactory({ conf, nodeName: 'node-1' })).filter(Boolean);
189
+ expect(documents.map((document) => document.kind)).to.deep.equal(['ConfigMap', 'Deployment']);
190
+
191
+ const deployment = documents.find((document) => document.kind === 'Deployment');
192
+ const container = deployment.spec.template.spec.containers[0];
193
+ expect(deployment.spec.template.spec.hostNetwork).to.equal(true);
194
+ expect(container.ports).to.equal(undefined);
195
+ expect(container.securityContext.capabilities.add).to.have.members([
196
+ 'NET_BIND_SERVICE',
197
+ 'CHOWN',
198
+ 'SETUID',
199
+ 'SETGID',
200
+ ]);
201
+
202
+ const configMap = documents.find((document) => document.kind === 'ConfigMap');
203
+ expect(configMap.data['nginx.conf'].trimEnd()).to.equal(conf.trimEnd());
204
+ });
205
+
206
+ it('validates and reloads a live config before persisting it', () => {
207
+ const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
208
+ const install = clusterSource.slice(
209
+ clusterSource.indexOf(' installUnderpostIngress('),
210
+ clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
211
+ );
212
+ const testCandidate = install.indexOf("execIngress('nginx -t -c /tmp/nginx.candidate.conf'");
213
+ const reload = install.indexOf('nginx -s reload -c /tmp/nginx.conf', testCandidate);
214
+ const persist = install.indexOf("shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'", reload);
215
+ expect(testCandidate).to.be.greaterThan(-1);
216
+ expect(reload).to.be.greaterThan(testCandidate);
217
+ expect(persist).to.be.greaterThan(reload);
218
+ });
219
+
220
+ it('never inherits an application node flag during a route-table refresh', () => {
221
+ const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
222
+ const install = clusterSource.slice(
223
+ clusterSource.indexOf(' installUnderpostIngress('),
224
+ clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
225
+ );
226
+ expect(install).to.include("const requestedNode = options.ingressNode || '';");
227
+ expect(install).to.not.include('const requestedNode = options.nodeName || options.node;');
228
+ expect(install).to.include('requestedNode ||\n liveNode ||');
229
+ });
230
+
231
+ it('waits for an explicit ingress-node replacement before reporting refresh success', () => {
232
+ const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
233
+ const install = clusterSource.slice(
234
+ clusterSource.indexOf(' installUnderpostIngress('),
235
+ clusterSource.indexOf(' pruneEndpointlessService(', clusterSource.indexOf(' installUnderpostIngress(')),
236
+ );
237
+ const changesNode = install.indexOf('const changesNode =');
238
+ const preflight = install.indexOf('pod/${preflightPod}', changesNode);
239
+ const apply = install.indexOf('underpostIngressManifestsFactory({');
240
+ const rollout = install.indexOf('if (!canHotReload || changesNode)', apply);
241
+ const available = install.indexOf('--for=condition=Available', rollout);
242
+ const nginxTest = install.indexOf("execIngress('nginx -t -c /tmp/nginx.conf'", available);
243
+ const success = install.indexOf("logger.info('Underpost ingress applied'", rollout);
244
+ expect(changesNode).to.be.greaterThan(-1);
245
+ expect(preflight).to.be.greaterThan(changesNode);
246
+ expect(apply).to.be.greaterThan(preflight);
247
+ expect(rollout).to.be.greaterThan(apply);
248
+ expect(available).to.be.greaterThan(rollout);
249
+ expect(nginxTest).to.be.greaterThan(available);
250
+ expect(success).to.be.greaterThan(nginxTest);
251
+ });
252
+ });
253
+ });