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,710 @@
1
+ 'use strict';
2
+
3
+ import { expect } from 'chai';
4
+ import fs from 'fs-extra';
5
+ import {
6
+ curlStatusChainFactory,
7
+ hostRenderInstancesFactory,
8
+ instanceTrafficPlanFactory,
9
+ isTrafficServingFactory,
10
+ hostIngressFactsFactory,
11
+ nextTrafficFactory,
12
+ schedulableNodeFactory,
13
+ stopPlanFactory,
14
+ trafficFromRoutingInfoFactory,
15
+ trafficProbePathsFactory,
16
+ trafficTableRowsFactory,
17
+ } from '../src/server/conf.js';
18
+
19
+ // Pure resolution over the conf: the cluster lookups are injected, so every case
20
+ // here fixes the routed colour and the endpoint readiness explicitly.
21
+ const INSTANCES = [
22
+ { id: 'mmo-server', host: 'server.fixture.test', path: '/' },
23
+ { id: 'mmo-server-forest', host: 'server.fixture.test', path: '/FOREST' },
24
+ ];
25
+
26
+ const planFactory = ({ live = {}, ready = {}, requestedTraffic = '', instances = INSTANCES } = {}) =>
27
+ instanceTrafficPlanFactory({
28
+ instances,
29
+ requestedTraffic,
30
+ liveTrafficOf: (instance) => live[instance.id],
31
+ servesTraffic: (instance, colour) => ready[`${instance.id}:${colour}`] === true,
32
+ });
33
+
34
+ describe('blue/green traffic plan', () => {
35
+ describe('curlStatusChainFactory', () => {
36
+ it('extracts every followed response without duplicating -i headers', () => {
37
+ const raw = [
38
+ '< HTTP/1.1 301 Moved Permanently',
39
+ 'HTTP/1.1 301 Moved Permanently',
40
+ '< HTTP/2 200',
41
+ 'HTTP/2 200',
42
+ 'UNDERPOST_CURL_FINAL=200',
43
+ ].join('\n');
44
+ expect(curlStatusChainFactory(raw)).to.deep.equal(['301', '200']);
45
+ });
46
+
47
+ it('ignores proxy CONNECT acknowledgements and reports no-response as 000', () => {
48
+ expect(curlStatusChainFactory('< HTTP/1.1 200 Connection established\nUNDERPOST_CURL_FINAL=000')).to.deep.equal([
49
+ '000',
50
+ ]);
51
+ });
52
+
53
+ it('uses curl write-out when no verbose response line was emitted', () => {
54
+ expect(curlStatusChainFactory('UNDERPOST_CURL_FINAL=204')).to.deep.equal(['204']);
55
+ });
56
+ });
57
+
58
+ describe('nextTrafficFactory', () => {
59
+ it('flips the live colour', () => {
60
+ expect(nextTrafficFactory('blue')).to.equal('green');
61
+ expect(nextTrafficFactory('green')).to.equal('blue');
62
+ });
63
+
64
+ it('starts on blue when no colour is live', () => {
65
+ for (const live of ['', undefined, null]) expect(nextTrafficFactory(live)).to.equal('blue');
66
+ });
67
+
68
+ it('honours an explicitly requested colour over the flip', () => {
69
+ expect(nextTrafficFactory('blue', 'blue')).to.equal('blue');
70
+ expect(nextTrafficFactory('green', 'green')).to.equal('green');
71
+ });
72
+
73
+ it('ignores a request that is not a colour', () => {
74
+ for (const requested of ['', 'red', 'BLUE', undefined])
75
+ expect(nextTrafficFactory('blue', requested)).to.equal('green');
76
+ });
77
+ });
78
+
79
+ // `run sync` gates its no-backend checkpoint on this directly; `run instance`
80
+ // reaches it through instanceTrafficPlanFactory. One definition, so neither
81
+ // runner can decide differently about taking a live host offline.
82
+ describe('isTrafficServingFactory', () => {
83
+ it('is serving only when the routed colour is also reachable', () => {
84
+ expect(isTrafficServingFactory({ liveTraffic: 'blue', hasReadyEndpoints: () => true })).to.equal(true);
85
+ expect(isTrafficServingFactory({ liveTraffic: 'blue', hasReadyEndpoints: () => false })).to.equal(false);
86
+ });
87
+
88
+ it('is not serving when no colour is routed', () => {
89
+ for (const liveTraffic of ['', undefined, null])
90
+ expect(isTrafficServingFactory({ liveTraffic, hasReadyEndpoints: () => true })).to.equal(false);
91
+ });
92
+
93
+ it('asks about the routed colour, and only when one is routed', () => {
94
+ const asked = [];
95
+ isTrafficServingFactory({
96
+ liveTraffic: 'green',
97
+ hasReadyEndpoints: (colour) => {
98
+ asked.push(colour);
99
+ return true;
100
+ },
101
+ });
102
+ isTrafficServingFactory({
103
+ liveTraffic: '',
104
+ hasReadyEndpoints: (colour) => {
105
+ asked.push(colour);
106
+ return true;
107
+ },
108
+ });
109
+ expect(asked).to.deep.equal(['green']);
110
+ });
111
+
112
+ it('defaults to not serving with nothing supplied', () => {
113
+ expect(isTrafficServingFactory({})).to.equal(false);
114
+ });
115
+ });
116
+
117
+ describe('instanceTrafficPlanFactory', () => {
118
+ it('flips every instance off the colour it serves', () => {
119
+ const { liveTrafficById, targetTrafficById } = planFactory({
120
+ live: { 'mmo-server': 'blue', 'mmo-server-forest': 'green' },
121
+ ready: { 'mmo-server:blue': true, 'mmo-server-forest:green': true },
122
+ });
123
+ expect(liveTrafficById).to.deep.equal({ 'mmo-server': 'blue', 'mmo-server-forest': 'green' });
124
+ expect(targetTrafficById).to.deep.equal({ 'mmo-server': 'green', 'mmo-server-forest': 'blue' });
125
+ });
126
+
127
+ // The regression this guards: a re-deploy of a healthy host must not be
128
+ // mistaken for a first bring-up, because the no-backend fallback checkpoint
129
+ // gated on `serving` routes the edge at a colour that has no Deployment.
130
+ it('reports a routed colour with a ready endpoint as serving', () => {
131
+ const { serving } = planFactory({
132
+ live: { 'mmo-server': 'blue', 'mmo-server-forest': 'blue' },
133
+ ready: { 'mmo-server:blue': true, 'mmo-server-forest:blue': true },
134
+ });
135
+ expect(serving.map((instance) => instance.id)).to.deep.equal(['mmo-server', 'mmo-server-forest']);
136
+ });
137
+
138
+ it('reports nothing serving on a first bring-up', () => {
139
+ const { serving, targetTrafficById } = planFactory({ live: {}, ready: {} });
140
+ expect(serving).to.deep.equal([]);
141
+ expect(targetTrafficById).to.deep.equal({ 'mmo-server': 'blue', 'mmo-server-forest': 'blue' });
142
+ });
143
+
144
+ // A route can still name a colour whose workload is long gone; that is a
145
+ // dead route, not traffic, so the checkpoint is free to run.
146
+ it('does not treat a routed colour without a ready endpoint as serving', () => {
147
+ const { serving, targetTrafficById } = planFactory({
148
+ live: { 'mmo-server': 'blue', 'mmo-server-forest': 'blue' },
149
+ ready: {},
150
+ });
151
+ expect(serving).to.deep.equal([]);
152
+ expect(targetTrafficById).to.deep.equal({ 'mmo-server': 'green', 'mmo-server-forest': 'green' });
153
+ });
154
+
155
+ it('counts only the instances that are actually serving', () => {
156
+ const { serving } = planFactory({
157
+ live: { 'mmo-server': 'blue', 'mmo-server-forest': 'blue' },
158
+ ready: { 'mmo-server:blue': true },
159
+ });
160
+ expect(serving.map((instance) => instance.id)).to.deep.equal(['mmo-server']);
161
+ });
162
+
163
+ // Readiness is asked about the live colour only. An idle colour left Ready by
164
+ // a previous cycle is not what the host is serving on.
165
+ it('ignores readiness of a colour that is not routed', () => {
166
+ const { serving } = planFactory({
167
+ live: { 'mmo-server': 'blue' },
168
+ ready: { 'mmo-server:green': true },
169
+ instances: [INSTANCES[0]],
170
+ });
171
+ expect(serving).to.deep.equal([]);
172
+ });
173
+
174
+ it('never consults readiness when no colour is routed', () => {
175
+ const asked = [];
176
+ const { serving } = instanceTrafficPlanFactory({
177
+ instances: INSTANCES,
178
+ liveTrafficOf: () => '',
179
+ servesTraffic: (instance, colour) => {
180
+ asked.push(`${instance.id}:${colour}`);
181
+ return true;
182
+ },
183
+ });
184
+ expect(asked).to.deep.equal([]);
185
+ expect(serving).to.deep.equal([]);
186
+ });
187
+
188
+ it('applies a requested colour to every instance', () => {
189
+ const { targetTrafficById } = planFactory({
190
+ live: { 'mmo-server': 'blue', 'mmo-server-forest': 'green' },
191
+ requestedTraffic: 'blue',
192
+ });
193
+ expect(targetTrafficById).to.deep.equal({ 'mmo-server': 'blue', 'mmo-server-forest': 'blue' });
194
+ });
195
+
196
+ it('is a no-op with no instances', () => {
197
+ expect(instanceTrafficPlanFactory({})).to.deep.equal({
198
+ liveTrafficById: {},
199
+ targetTrafficById: {},
200
+ serving: [],
201
+ });
202
+ });
203
+ });
204
+
205
+ // A host's server block and HTTPRoute are single shared objects, so what the
206
+ // render is built from decides which variants keep a route at all.
207
+ describe('hostRenderInstancesFactory', () => {
208
+ const MAIN = INSTANCES[0];
209
+ const FOREST = INSTANCES[1];
210
+ const TEST = { id: 'mmo-server-test', host: 'server.fixture.test', path: '/TEST' };
211
+
212
+ // The reported regression: trimming the conf to one variant must not delete
213
+ // the routes of the variants still deployed behind the same host.
214
+ it('keeps a still-deployed variant that the conf no longer declares', () => {
215
+ const rendered = hostRenderInstancesFactory({
216
+ declared: [FOREST],
217
+ preserved: [MAIN, FOREST, TEST],
218
+ isDeployed: () => true,
219
+ });
220
+ expect(rendered.map((instance) => instance.id)).to.deep.equal([
221
+ 'mmo-server-forest',
222
+ 'mmo-server',
223
+ 'mmo-server-test',
224
+ ]);
225
+ });
226
+
227
+ it('drops a preserved variant once its workload is gone', () => {
228
+ const rendered = hostRenderInstancesFactory({
229
+ declared: [FOREST],
230
+ preserved: [MAIN, TEST],
231
+ isDeployed: (instance) => instance.id === 'mmo-server',
232
+ });
233
+ expect(rendered.map((instance) => instance.id)).to.deep.equal(['mmo-server-forest', 'mmo-server']);
234
+ });
235
+
236
+ it('never duplicates a variant that is both declared and preserved', () => {
237
+ const rendered = hostRenderInstancesFactory({
238
+ declared: [MAIN, FOREST],
239
+ preserved: [MAIN, FOREST],
240
+ isDeployed: () => true,
241
+ });
242
+ expect(rendered.map((instance) => instance.id)).to.deep.equal(['mmo-server', 'mmo-server-forest']);
243
+ });
244
+
245
+ // The declared entry is the current truth for that id; a stale preserved copy
246
+ // of the same id must not shadow it.
247
+ it('prefers the declared entry over a preserved one of the same id', () => {
248
+ const rendered = hostRenderInstancesFactory({
249
+ declared: [{ ...FOREST, path: '/FOREST-NEW' }],
250
+ preserved: [{ ...FOREST, path: '/FOREST-OLD' }],
251
+ isDeployed: () => true,
252
+ });
253
+ expect(rendered).to.have.lengthOf(1);
254
+ expect(rendered[0].path).to.equal('/FOREST-NEW');
255
+ });
256
+
257
+ it('ignores preserved entries with no id', () => {
258
+ expect(
259
+ hostRenderInstancesFactory({ declared: [FOREST], preserved: [{}, null], isDeployed: () => true }),
260
+ ).to.deep.equal([FOREST]);
261
+ });
262
+
263
+ it('is the declared set when nothing was preserved', () => {
264
+ expect(hostRenderInstancesFactory({ declared: [MAIN, FOREST] })).to.deep.equal([MAIN, FOREST]);
265
+ expect(hostRenderInstancesFactory({})).to.deep.equal([]);
266
+ });
267
+ });
268
+
269
+ // A nodeSelector naming a node that does not exist does not degrade — nothing
270
+ // schedules — so the guessed default has to be checked against the cluster.
271
+ describe('schedulableNodeFactory', () => {
272
+ const KUBEADM = [{ NAME: 'localhost.localdomain', STATUS: 'Ready', ROLES: 'control-plane' }];
273
+ const KIND = [
274
+ { NAME: 'kind-control-plane', STATUS: 'Ready', ROLES: 'control-plane' },
275
+ { NAME: 'kind-worker', STATUS: 'Ready', ROLES: '<none>' },
276
+ ];
277
+
278
+ // The reported failure: `--dev` on a kubeadm cluster guesses `kind-worker`.
279
+ it('substitutes a real node when the guess does not exist', () => {
280
+ expect(schedulableNodeFactory({ nodes: KUBEADM, node: 'kind-worker' })).to.deep.equal({
281
+ node: 'localhost.localdomain',
282
+ corrected: true,
283
+ });
284
+ });
285
+
286
+ it('keeps a node the cluster actually has', () => {
287
+ expect(schedulableNodeFactory({ nodes: KIND, node: 'kind-worker' })).to.deep.equal({
288
+ node: 'kind-worker',
289
+ corrected: false,
290
+ });
291
+ });
292
+
293
+ it('prefers a worker over the control plane', () => {
294
+ expect(schedulableNodeFactory({ nodes: KIND, node: 'nope' }).node).to.equal('kind-worker');
295
+ });
296
+
297
+ it('falls back to the control plane when it is the only node', () => {
298
+ expect(schedulableNodeFactory({ nodes: KUBEADM, node: '' }).node).to.equal('localhost.localdomain');
299
+ });
300
+
301
+ it('skips a NotReady node but still answers when none are Ready', () => {
302
+ const nodes = [
303
+ { NAME: 'down', STATUS: 'NotReady', ROLES: '<none>' },
304
+ { NAME: 'up', STATUS: 'Ready', ROLES: '<none>' },
305
+ ];
306
+ expect(schedulableNodeFactory({ nodes, node: 'nope' }).node).to.equal('up');
307
+ expect(schedulableNodeFactory({ nodes: [nodes[0]], node: 'nope' }).node).to.equal('down');
308
+ });
309
+
310
+ it('treats a cordoned node as Ready', () => {
311
+ const nodes = [{ NAME: 'only', STATUS: 'Ready,SchedulingDisabled', ROLES: '<none>' }];
312
+ expect(schedulableNodeFactory({ nodes, node: 'nope' }).node).to.equal('only');
313
+ });
314
+
315
+ // An unreadable cluster is not evidence the name is wrong.
316
+ it('leaves the choice untouched when there is no node list', () => {
317
+ expect(schedulableNodeFactory({ nodes: [], node: 'kind-worker' })).to.deep.equal({
318
+ node: 'kind-worker',
319
+ corrected: false,
320
+ });
321
+ expect(schedulableNodeFactory({ nodes: [{}, null], node: 'kind-worker' }).corrected).to.equal(false);
322
+ });
323
+ });
324
+
325
+ describe('stopPlanFactory', () => {
326
+ const FAMILY = {
327
+ 'mmo-server': [
328
+ { id: 'mmo-server', host: 'server.fixture.test' },
329
+ { id: 'mmo-server-forest', host: 'server.fixture.test' },
330
+ ],
331
+ 'mmo-client': [{ id: 'mmo-client', host: 'client.fixture.test' }],
332
+ };
333
+ const plan = (input) =>
334
+ stopPlanFactory({
335
+ env: 'development',
336
+ instancesFor: (instanceId) => FAMILY[instanceId] || [],
337
+ liveTrafficOf: () => 'blue',
338
+ ...input,
339
+ });
340
+ const names = (input) => plan(input).deployments.map((target) => target.deployment);
341
+
342
+ // 1 — default is the partner of whatever is serving.
343
+ it('stops the inactive colour of the PWA workload', () => {
344
+ expect(names({ deployId: 'dd-cyberia' })).to.deep.equal(['dd-cyberia-development-green']);
345
+ });
346
+
347
+ it('stops the colours named by --traffic, both when both are given', () => {
348
+ expect(names({ deployId: 'dd-cyberia', traffic: 'blue,green' })).to.deep.equal([
349
+ 'dd-cyberia-development-blue',
350
+ 'dd-cyberia-development-green',
351
+ ]);
352
+ expect(names({ deployId: 'dd-cyberia', traffic: 'blue' })).to.deep.equal(['dd-cyberia-development-blue']);
353
+ });
354
+
355
+ it('refuses a --traffic value that is not a colour', () => {
356
+ expect(plan({ deployId: 'dd-cyberia', traffic: 'red' }).error).to.match(/--traffic accepts blue and\/or green/);
357
+ });
358
+
359
+ // 2 — a literal path names the object outright and overrides every flag.
360
+ it('stops exactly the literal deployment, ignoring flags', () => {
361
+ const result = plan({
362
+ path: 'dd-cyberia-mmo-server-forest-development-blue',
363
+ deployId: 'dd-other',
364
+ instanceId: 'mmo-client',
365
+ traffic: 'blue,green',
366
+ });
367
+ expect(result.deployments.map((target) => target.deployment)).to.deep.equal([
368
+ 'dd-cyberia-mmo-server-forest-development-blue',
369
+ ]);
370
+ expect(result.deployments[0].kind).to.equal('literal');
371
+ });
372
+
373
+ it('accepts several literal deployments', () => {
374
+ expect(names({ path: 'a-development-blue, b-development-green' })).to.deep.equal([
375
+ 'a-development-blue',
376
+ 'b-development-green',
377
+ ]);
378
+ });
379
+
380
+ // 3 + 4 — instance ids add their whole family alongside the PWA workload.
381
+ it('adds every variant of each instance family', () => {
382
+ expect(names({ deployId: 'dd-cyberia', instanceId: 'mmo-client,mmo-server' })).to.deep.equal([
383
+ 'dd-cyberia-development-green',
384
+ 'dd-cyberia-mmo-client-development-green',
385
+ 'dd-cyberia-mmo-server-development-green',
386
+ 'dd-cyberia-mmo-server-forest-development-green',
387
+ ]);
388
+ });
389
+
390
+ it('applies explicit colours to the PWA workload and every instance alike', () => {
391
+ expect(names({ deployId: 'dd-cyberia', instanceId: 'mmo-client', traffic: 'blue,green' })).to.deep.equal([
392
+ 'dd-cyberia-development-blue',
393
+ 'dd-cyberia-development-green',
394
+ 'dd-cyberia-mmo-client-development-blue',
395
+ 'dd-cyberia-mmo-client-development-green',
396
+ ]);
397
+ });
398
+
399
+ it('resolves each target its own colour', () => {
400
+ const deployments = plan({
401
+ deployId: 'dd-cyberia',
402
+ instanceId: 'mmo-server',
403
+ liveTrafficOf: (target) => (target.id === 'dd-cyberia-mmo-server' ? 'green' : 'blue'),
404
+ }).deployments;
405
+ expect(deployments.map((target) => target.deployment)).to.deep.equal([
406
+ 'dd-cyberia-development-green',
407
+ 'dd-cyberia-mmo-server-development-blue',
408
+ 'dd-cyberia-mmo-server-forest-development-green',
409
+ ]);
410
+ });
411
+
412
+ it('never plans the same deployment twice', () => {
413
+ expect(names({ deployId: 'dd-cyberia', instanceId: 'mmo-server,mmo-server' })).to.deep.equal([
414
+ 'dd-cyberia-development-green',
415
+ 'dd-cyberia-mmo-server-development-green',
416
+ 'dd-cyberia-mmo-server-forest-development-green',
417
+ ]);
418
+ });
419
+
420
+ // 5 — an instance id is only unique inside a deploy.
421
+ it('refuses --instance-id without --deploy-id', () => {
422
+ const result = plan({ instanceId: 'mmo-server' });
423
+ expect(result.error).to.match(/--instance-id requires --deploy-id/);
424
+ expect(result.deployments).to.deep.equal([]);
425
+ });
426
+
427
+ it('refuses a plan with nothing to act on', () => {
428
+ expect(plan({}).error).to.match(/nothing to stop/);
429
+ });
430
+
431
+ it('passes the instance host through for colour resolution', () => {
432
+ const asked = [];
433
+ plan({
434
+ deployId: 'dd-cyberia',
435
+ instanceId: 'mmo-client',
436
+ liveTrafficOf: (target) => {
437
+ asked.push([target.id, target.host, target.kind]);
438
+ return 'blue';
439
+ },
440
+ });
441
+ expect(asked).to.deep.equal([
442
+ ['dd-cyberia', '', 'pwa'],
443
+ ['dd-cyberia-mmo-client', 'client.fixture.test', 'instance'],
444
+ ]);
445
+ });
446
+ });
447
+
448
+ // One host's routing text is matched against several deployments and both
449
+ // environments, so the match has to be exact about which name it is reading.
450
+ describe('trafficFromRoutingInfoFactory', () => {
451
+ const INFO = [
452
+ 'name: dd-cyberia-mmo-server-development-green-service',
453
+ 'name: dd-cyberia-mmo-server-forest-development-blue-service',
454
+ 'name: dd-cyberia-development-blue-service',
455
+ ].join('\n');
456
+
457
+ it('reads each deployment its own colour from a shared host', () => {
458
+ expect(trafficFromRoutingInfoFactory({ info: INFO, deployId: 'dd-cyberia', env: 'development' })).to.equal(
459
+ 'blue',
460
+ );
461
+ expect(
462
+ trafficFromRoutingInfoFactory({ info: INFO, deployId: 'dd-cyberia-mmo-server', env: 'development' }),
463
+ ).to.equal('green');
464
+ expect(
465
+ trafficFromRoutingInfoFactory({ info: INFO, deployId: 'dd-cyberia-mmo-server-forest', env: 'development' }),
466
+ ).to.equal('blue');
467
+ });
468
+
469
+ it('reads the colour from the stable Service selector', () => {
470
+ expect(
471
+ trafficFromRoutingInfoFactory({
472
+ info: 'dd-cyberia-development-green',
473
+ deployId: 'dd-cyberia',
474
+ env: 'development',
475
+ }),
476
+ ).to.equal('green');
477
+ });
478
+
479
+ // The reported failure: a development cluster reported as entirely unrouted
480
+ // because every match was anchored on `-production-`.
481
+ it('finds nothing for an environment the routing does not mention', () => {
482
+ expect(trafficFromRoutingInfoFactory({ info: INFO, deployId: 'dd-cyberia', env: 'production' })).to.equal(null);
483
+ });
484
+
485
+ it('is null when there is no routing text', () => {
486
+ for (const info of ['', ' \n ', undefined])
487
+ expect(trafficFromRoutingInfoFactory({ info, deployId: 'dd-cyberia', env: 'development' })).to.equal(null);
488
+ });
489
+
490
+ it('does not let a regex metacharacter in the id match loosely', () => {
491
+ expect(
492
+ trafficFromRoutingInfoFactory({
493
+ info: 'name: dd-any-development-blue-service',
494
+ deployId: 'dd.any',
495
+ env: 'development',
496
+ }),
497
+ ).to.equal(null);
498
+ });
499
+
500
+ it('falls back to a whole-text match with no environment', () => {
501
+ expect(trafficFromRoutingInfoFactory({ info: 'routes to green here', deployId: 'dd-cyberia' })).to.equal('green');
502
+ expect(trafficFromRoutingInfoFactory({ info: 'nothing routed', deployId: 'dd-cyberia' })).to.equal(null);
503
+ });
504
+ });
505
+
506
+ // None of these three is readable from the route object alone, which is why
507
+ // the report correlates four kinds instead of reading one.
508
+ describe('hostIngressFactsFactory', () => {
509
+ const GATEWAYS = [
510
+ {
511
+ metadata: { name: 'dd-cyberia-development' },
512
+ spec: {
513
+ listeners: [
514
+ { name: 'http', protocol: 'HTTP' },
515
+ { name: 'https', protocol: 'HTTPS', tls: { certificateRefs: [{ name: 'underpost.net' }] } },
516
+ ],
517
+ },
518
+ },
519
+ { metadata: { name: 'dd-plain-development' }, spec: { listeners: [{ name: 'http', protocol: 'HTTP' }] } },
520
+ ];
521
+ const POLICIES = [
522
+ { spec: { targetRefs: [{ kind: 'Gateway', name: 'dd-cyberia-development', sectionName: 'https' }], http3: {} } },
523
+ ];
524
+
525
+ it('reads the kind, TLS and HTTP/3 for a Gateway API host', () => {
526
+ const facts = hostIngressFactsFactory({
527
+ httpRoutes: [{ spec: { hostnames: ['underpost.net'], parentRefs: [{ name: 'dd-cyberia-development' }] } }],
528
+ gateways: GATEWAYS,
529
+ clientTrafficPolicies: POLICIES,
530
+ });
531
+ expect(facts['underpost.net']).to.deep.equal({ route: 'HTTPRoute', tls: true, http3: true });
532
+ });
533
+
534
+ it('reads TLS from an HTTPProxy virtualhost, and never claims HTTP/3 for it', () => {
535
+ const facts = hostIngressFactsFactory({
536
+ httpProxies: [
537
+ { spec: { virtualhost: { fqdn: 'legacy.test', tls: { secretName: 'legacy' } } } },
538
+ { spec: { virtualhost: { fqdn: 'plain.test' } } },
539
+ ],
540
+ });
541
+ expect(facts['legacy.test']).to.deep.equal({ route: 'HTTPProxy', tls: true, http3: false });
542
+ expect(facts['plain.test']).to.deep.equal({ route: 'HTTPProxy', tls: false, http3: false });
543
+ });
544
+
545
+ it('reports no TLS for a Gateway with only a plain HTTP listener', () => {
546
+ const facts = hostIngressFactsFactory({
547
+ httpRoutes: [{ spec: { hostnames: ['plain.test'], parentRefs: [{ name: 'dd-plain-development' }] } }],
548
+ gateways: GATEWAYS,
549
+ });
550
+ expect(facts['plain.test']).to.deep.equal({ route: 'HTTPRoute', tls: false, http3: false });
551
+ });
552
+
553
+ // QUIC only exists where TLS does, so a policy on a plain listener describes
554
+ // nothing — the implementation rejects it too.
555
+ it('does not claim HTTP/3 from a policy targeting a Gateway with no TLS listener', () => {
556
+ const facts = hostIngressFactsFactory({
557
+ httpRoutes: [{ spec: { hostnames: ['plain.test'], parentRefs: [{ name: 'dd-plain-development' }] } }],
558
+ gateways: GATEWAYS,
559
+ clientTrafficPolicies: [{ spec: { targetRefs: [{ name: 'dd-plain-development' }], http3: {} } }],
560
+ });
561
+ expect(facts['plain.test'].http3).to.equal(false);
562
+ });
563
+
564
+ it('lets the HTTPRoute win a host described by both kinds', () => {
565
+ const facts = hostIngressFactsFactory({
566
+ httpRoutes: [{ spec: { hostnames: ['dual.test'], parentRefs: [{ name: 'dd-cyberia-development' }] } }],
567
+ httpProxies: [{ spec: { virtualhost: { fqdn: 'dual.test' } } }],
568
+ gateways: GATEWAYS,
569
+ clientTrafficPolicies: POLICIES,
570
+ });
571
+ expect(facts['dual.test']).to.deep.equal({ route: 'HTTPRoute', tls: true, http3: true });
572
+ });
573
+
574
+ it('yields nothing for missing CRDs or malformed items', () => {
575
+ expect(hostIngressFactsFactory()).to.deep.equal({});
576
+ expect(hostIngressFactsFactory({ httpProxies: [{ spec: {} }], httpRoutes: [{ spec: {} }] })).to.deep.equal({});
577
+ });
578
+ });
579
+
580
+ describe('trafficProbePathsFactory', () => {
581
+ it('probes only the canonical path when no replicas are declared', () => {
582
+ expect(trafficProbePathsFactory({}, '/wp')).to.deep.equal(['/wp']);
583
+ expect(trafficProbePathsFactory(undefined, '/wp')).to.deep.equal(['/wp']);
584
+ });
585
+
586
+ // '/' in engine-private/conf/dd-test/conf.server.json: replicas without
587
+ // singleReplica, so the canonical path is still actually served.
588
+ it('probes the canonical path plus every replica when singleReplica is not set', () => {
589
+ expect(trafficProbePathsFactory({ replicas: ['/r1'] }, '/')).to.deep.equal(['/', '/r1']);
590
+ });
591
+
592
+ // '/single-replica' in the same fixture: singleReplica means the canonical
593
+ // path is never itself served, only its replicas are.
594
+ it('probes only the replicas, dropping the canonical path, when singleReplica is set', () => {
595
+ expect(trafficProbePathsFactory({ replicas: ['/r2'], singleReplica: true }, '/single-replica')).to.deep.equal([
596
+ '/r2',
597
+ ]);
598
+ });
599
+
600
+ it('treats a non-array replicas value as no replicas', () => {
601
+ expect(trafficProbePathsFactory({ replicas: 'not-an-array' }, '/wp')).to.deep.equal(['/wp']);
602
+ });
603
+ });
604
+
605
+ describe('trafficTableRowsFactory', () => {
606
+ const ENTRIES = [
607
+ { kind: 'pwa', deployId: 'dd-a', id: 'dd-a', host: 'a.test', path: '/', deployment: 'dd-a-development' },
608
+ {
609
+ kind: 'instance',
610
+ deployId: 'dd-a',
611
+ id: 'dd-a-mmo-server',
612
+ host: 'server.test',
613
+ path: '/',
614
+ deployment: 'dd-a-mmo-server-development',
615
+ },
616
+ ];
617
+
618
+ it('reports both deployment kinds with their live colour', () => {
619
+ const rows = trafficTableRowsFactory({
620
+ entries: ENTRIES,
621
+ liveTrafficOf: (entry) => (entry.kind === 'pwa' ? 'blue' : 'green'),
622
+ servesTraffic: () => true,
623
+ });
624
+ expect(rows.map((row) => [row.kind, row.traffic, row.serving])).to.deep.equal([
625
+ ['pwa', 'blue', true],
626
+ ['instance', 'green', true],
627
+ ]);
628
+ });
629
+
630
+ it('narrows to the requested hosts', () => {
631
+ const rows = trafficTableRowsFactory({ entries: ENTRIES, hosts: ['server.test'], liveTrafficOf: () => 'blue' });
632
+ expect(rows.map((row) => row.host)).to.deep.equal(['server.test']);
633
+ });
634
+
635
+ it('reports every host when none are requested', () => {
636
+ for (const hosts of [[], ['']])
637
+ expect(trafficTableRowsFactory({ entries: ENTRIES, hosts, liveTrafficOf: () => 'blue' })).to.have.lengthOf(2);
638
+ });
639
+
640
+ // A deployment with no published route is a real state to report, not a row
641
+ // to hide: it is exactly what a half-finished promote looks like.
642
+ it('keeps an entry whose colour cannot be read', () => {
643
+ const rows = trafficTableRowsFactory({ entries: ENTRIES, liveTrafficOf: () => null, servesTraffic: () => true });
644
+ expect(rows.map((row) => [row.traffic, row.serving])).to.deep.equal([
645
+ ['', false],
646
+ ['', false],
647
+ ]);
648
+ });
649
+
650
+ it('keeps configured report rows when no Kubernetes deployments exist', () => {
651
+ const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
652
+ const start = runSource.indexOf(" 'get-traffic': async");
653
+ const end = runSource.indexOf(" 'instance-promote': async", start);
654
+ const getTraffic = runSource.slice(start, end);
655
+ expect(getTraffic).to.include('.map((entry) => ({ ...entry, env }))');
656
+ expect(getTraffic).not.to.include('.filter((entry) => deployedNames.some');
657
+ });
658
+
659
+ it('reports a routed colour with no ready endpoint as not serving', () => {
660
+ const rows = trafficTableRowsFactory({
661
+ entries: [ENTRIES[0]],
662
+ liveTrafficOf: () => 'blue',
663
+ servesTraffic: () => false,
664
+ });
665
+ expect(rows[0]).to.include({ traffic: 'blue', serving: false });
666
+ });
667
+
668
+ it('is empty with no entries', () => {
669
+ expect(trafficTableRowsFactory({})).to.deep.equal([]);
670
+ });
671
+
672
+ it('feeds the report from stable Service selectors before legacy route text', () => {
673
+ const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
674
+ const start = runSource.indexOf(" 'get-traffic': async");
675
+ const end = runSource.indexOf(" 'instance-promote': async", start);
676
+ const getTraffic = runSource.slice(start, end);
677
+ const listServices = getTraffic.indexOf("listResources('service')");
678
+ const stableSelector = getTraffic.indexOf('const stableTraffic = trafficFromRoutingInfoFactory');
679
+ const legacyRoute = getTraffic.indexOf('const legacyTraffic = trafficFromRoutingInfoFactory');
680
+ expect(listServices).to.be.greaterThan(-1);
681
+ expect(stableSelector).to.be.greaterThan(listServices);
682
+ expect(legacyRoute).to.be.greaterThan(stableSelector);
683
+ });
684
+
685
+ it('uses real followed curl probes and reports current and opposite deployment status without ENV', () => {
686
+ const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
687
+ const start = runSource.indexOf(" 'get-traffic': async");
688
+ const end = runSource.indexOf(" 'instance-promote': async", start);
689
+ const getTraffic = runSource.slice(start, end);
690
+ expect(getTraffic).to.include('curl -L -v -i -s');
691
+ expect(getTraffic).to.include("'OPPOSITE'");
692
+ expect(getTraffic).not.to.include('showOpposite');
693
+ expect(getTraffic).to.include("'CURRENT'");
694
+ expect(getTraffic).not.to.include("'ENV',");
695
+ expect(getTraffic).to.include('opposite,');
696
+ expect(getTraffic).to.include("status.exists ? (status.ready ? 'ready' : 'not-ready') : 'missing'");
697
+ expect(getTraffic).to.include("status.serving ? 'serving' : 'not-serving'");
698
+ expect(getTraffic).to.include("`${probe.path} [${probe.statuses.join('→')}]`");
699
+ });
700
+
701
+ it('caches stable Service readiness across hosts sharing the same deployment', () => {
702
+ const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
703
+ const start = runSource.indexOf(" 'get-traffic': async");
704
+ const end = runSource.indexOf(" 'instance-promote': async", start);
705
+ const getTraffic = runSource.slice(start, end);
706
+ expect(getTraffic).to.include('const servingState = {}');
707
+ expect(getTraffic).to.include('if (servingState[key] === undefined)');
708
+ });
709
+ });
710
+ });