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.
- package/CHANGELOG.md +182 -1
- package/CLI-HELP.md +37 -16
- package/README.md +2 -2
- 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 +17 -17
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/cluster.js +740 -55
- package/src/cli/db.js +2 -2
- package/src/cli/deploy.js +1679 -174
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/image.js +15 -6
- package/src/cli/index.js +124 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2161 -420
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +8 -28
- 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 +1208 -70
- package/src/server/cri.js +70 -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,435 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import {
|
|
6
|
+
clusterContextFactory,
|
|
7
|
+
clusterInstancesFactory,
|
|
8
|
+
clusterTypeFactory,
|
|
9
|
+
deployHostsFactory,
|
|
10
|
+
etcHostFactory,
|
|
11
|
+
gatewayApiEnabledFactory,
|
|
12
|
+
instanceInterceptStatusesFactory,
|
|
13
|
+
instanceProjectPathFactory,
|
|
14
|
+
instanceStatusPageEntriesFactory,
|
|
15
|
+
loadConfInstances,
|
|
16
|
+
loadProjectInstanceEnvBuilder,
|
|
17
|
+
normalizeInstanceTopology,
|
|
18
|
+
} from '../src/server/conf.js';
|
|
19
|
+
import { statusPageAssetPathFactory } from '../src/server/underpost-gateway.js';
|
|
20
|
+
import UnderpostDockerCompose from '../src/cli/docker-compose.js';
|
|
21
|
+
|
|
22
|
+
// `clusterInstancesFactory` reads `./engine-private/conf/<deployId>/conf.instances.json`
|
|
23
|
+
// relative to the process cwd, mirroring every other conf loader. engine-private
|
|
24
|
+
// is a private repository, so each fixture gets its own deploy directory, that
|
|
25
|
+
// directory is removed whole afterwards, and an existing one is never touched.
|
|
26
|
+
const CONF_DIR = (deployId) => `./engine-private/conf/${deployId}`;
|
|
27
|
+
|
|
28
|
+
const SERVER_FIXTURE = {
|
|
29
|
+
'dd-fixture-a': { 'app.fixture.test': { '/': { client: 'App' } } },
|
|
30
|
+
'dd-fixture-b': { 'b.fixture.test': { '/': { client: 'B' } } },
|
|
31
|
+
'dd-fixture-legacy-env': {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FIXTURES = {
|
|
35
|
+
'dd-fixture-a': [
|
|
36
|
+
{
|
|
37
|
+
id: 'mmo-server',
|
|
38
|
+
host: 'server.fixture.test',
|
|
39
|
+
path: '/',
|
|
40
|
+
runtime: 'fixture-runtime',
|
|
41
|
+
metadata: { repository: 'underpostnet/fixture-server' },
|
|
42
|
+
customStatusPages: [{ status: '404', hostPath: './public/404/index.html' }],
|
|
43
|
+
multiInstance: {
|
|
44
|
+
variants: ['/', '/FOREST'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{ id: 'mmo-client', host: 'client.fixture.test', path: '/' },
|
|
48
|
+
],
|
|
49
|
+
'dd-fixture-b': [{ id: 'worker', host: 'worker.fixture.test', path: '/' }],
|
|
50
|
+
'dd-fixture-legacy-env': [
|
|
51
|
+
{
|
|
52
|
+
id: 'legacy',
|
|
53
|
+
multiInstance: {
|
|
54
|
+
env: { INSTANCE_CODE: '{{code}}' },
|
|
55
|
+
variants: ['/MAIN'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe('cluster custom instances', () => {
|
|
62
|
+
const created = [];
|
|
63
|
+
|
|
64
|
+
before(() => {
|
|
65
|
+
for (const [deployId, entries] of Object.entries(FIXTURES)) {
|
|
66
|
+
const dir = CONF_DIR(deployId);
|
|
67
|
+
if (fs.existsSync(dir)) throw new Error(`Refusing to write fixtures into an existing deploy: ${dir}`);
|
|
68
|
+
fs.outputJsonSync(`${dir}/conf.instances.json`, entries);
|
|
69
|
+
fs.outputJsonSync(`${dir}/conf.server.json`, SERVER_FIXTURE[deployId]);
|
|
70
|
+
created.push(dir);
|
|
71
|
+
}
|
|
72
|
+
const customComposeDir = `${CONF_DIR('dd-fixture-a')}/docker-compose/custom-stack`;
|
|
73
|
+
fs.outputFileSync(`${customComposeDir}/docker-compose.yml`, 'services: {}\n');
|
|
74
|
+
fs.outputFileSync(`${customComposeDir}/compose.env`, 'FIXTURE=true\n');
|
|
75
|
+
fs.outputFileSync(`${customComposeDir}/project-router.conf`, 'project-owned\n');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
after(() => {
|
|
79
|
+
for (const dir of created) fs.removeSync(dir);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('multi-instance topology and env dispatch', () => {
|
|
83
|
+
it('derives code, lowercase slug, and path from compact path variants', () => {
|
|
84
|
+
expect(normalizeInstanceTopology({ variants: ['/', '/FOREST'] })).to.deep.equal({
|
|
85
|
+
variants: [
|
|
86
|
+
{ code: '', slug: '', path: '/', isDefault: true },
|
|
87
|
+
{ code: 'FOREST', slug: '/forest', path: '/FOREST', isDefault: false },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('prepends the normal root build when variants omit it', () => {
|
|
93
|
+
expect(normalizeInstanceTopology({ variants: ['/FOREST'] }).variants[0]).to.deep.equal({
|
|
94
|
+
code: '',
|
|
95
|
+
slug: '',
|
|
96
|
+
path: '/',
|
|
97
|
+
isDefault: true,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects the removed object variant schema', () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
normalizeInstanceTopology({
|
|
104
|
+
variants: [{ code: 'FOREST', slug: 'forest', path: '/FOREST' }],
|
|
105
|
+
}),
|
|
106
|
+
).to.throw('must contain only path strings');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps project env values out of expanded topology objects', () => {
|
|
110
|
+
const [main, forest] = loadConfInstances('dd-fixture-a');
|
|
111
|
+
expect(main).not.to.have.property('instanceEnv');
|
|
112
|
+
expect(forest).not.to.have.property('instanceEnv');
|
|
113
|
+
expect(main).to.include({
|
|
114
|
+
id: 'mmo-server',
|
|
115
|
+
instanceCode: '',
|
|
116
|
+
instanceSlug: '',
|
|
117
|
+
path: '/',
|
|
118
|
+
isDefaultInstance: true,
|
|
119
|
+
});
|
|
120
|
+
expect(forest).to.include({
|
|
121
|
+
id: 'mmo-server-forest',
|
|
122
|
+
instanceCode: 'FOREST',
|
|
123
|
+
instanceSlug: '/forest',
|
|
124
|
+
path: '/FOREST',
|
|
125
|
+
isDefaultInstance: false,
|
|
126
|
+
});
|
|
127
|
+
expect(forest).not.to.have.property('pathRewritePolicy');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects the legacy topology env map with migration guidance', () => {
|
|
131
|
+
expect(() => loadConfInstances('dd-fixture-legacy-env')).to.throw(
|
|
132
|
+
/uses removed multiInstance\.env.*dispatch env builder/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('loads an optional project env builder by deploy-id convention', async () => {
|
|
137
|
+
const builder = await loadProjectInstanceEnvBuilder('dd-cyberia');
|
|
138
|
+
if (fs.existsSync('./src/projects/cyberia/instance-data.js'))
|
|
139
|
+
expect(builder).to.be.a('function').and.have.property('name', 'buildCyberiaMmoInstanceEnv');
|
|
140
|
+
else expect(builder).to.equal(null);
|
|
141
|
+
expect(await loadProjectInstanceEnvBuilder('dd-fixture-a')).to.equal(null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('uses a named compose workflow without rewriting project-owned files', () => {
|
|
145
|
+
const options = { deployId: 'dd-fixture-a', dockerComposeId: 'custom-stack' };
|
|
146
|
+
const customComposeDir = 'engine-private/conf/dd-fixture-a/docker-compose/custom-stack';
|
|
147
|
+
const routerPath = `${customComposeDir}/project-router.conf`;
|
|
148
|
+
expect(UnderpostDockerCompose.composeIdBase(options)).to.equal(customComposeDir);
|
|
149
|
+
expect(() => UnderpostDockerCompose.generate(options)).not.to.throw();
|
|
150
|
+
expect(fs.readFileSync(routerPath, 'utf8')).to.equal('project-owned\n');
|
|
151
|
+
expect(UnderpostDockerCompose.baseCmd(options)).to.include(
|
|
152
|
+
`--project-directory ${process.cwd()}/${customComposeDir}`,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('writes one idempotent identified hosts block without replacing unrelated entries', () => {
|
|
157
|
+
const customComposeDir = 'engine-private/conf/dd-fixture-a/docker-compose/custom-stack';
|
|
158
|
+
const hostsPath = `${customComposeDir}/hosts`;
|
|
159
|
+
fs.writeFileSync(hostsPath, '127.0.0.1 localhost\n', 'utf8');
|
|
160
|
+
const options = { path: hostsPath, append: true, blockId: 'fixture-docker-compose' };
|
|
161
|
+
expect(etcHostFactory(['fixture-client', 'fixture-server', 'fixture-engine'], options).changed).to.equal(
|
|
162
|
+
true,
|
|
163
|
+
);
|
|
164
|
+
expect(etcHostFactory(['fixture-client', 'fixture-server', 'fixture-engine'], options).changed).to.equal(
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
const hosts = fs.readFileSync(hostsPath, 'utf8');
|
|
168
|
+
expect(hosts.match(/underpost hosts fixture-docker-compose:begin/g)).to.have.length(1);
|
|
169
|
+
expect(hosts).to.include('127.0.0.1 localhost');
|
|
170
|
+
expect(hosts).to.include('fixture-client fixture-server fixture-engine');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('binds an instance to the deploy that declares it', () => {
|
|
175
|
+
const { byDeployId, unmatched } = clusterInstancesFactory(['dd-fixture-a', 'dd-fixture-b'], 'mmo-server');
|
|
176
|
+
expect(byDeployId['dd-fixture-a']).to.deep.equal({
|
|
177
|
+
ids: ['mmo-server'],
|
|
178
|
+
hosts: ['server.fixture.test'],
|
|
179
|
+
});
|
|
180
|
+
expect(byDeployId['dd-fixture-b']).to.deep.equal({ ids: [], hosts: [] });
|
|
181
|
+
expect(unmatched).to.deep.equal([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('resolves several instances across several deploys', () => {
|
|
185
|
+
const { byDeployId, unmatched } = clusterInstancesFactory(
|
|
186
|
+
['dd-fixture-a', 'dd-fixture-b'],
|
|
187
|
+
'mmo-server+mmo-client+worker',
|
|
188
|
+
);
|
|
189
|
+
expect(byDeployId['dd-fixture-a'].ids).to.deep.equal(['mmo-server', 'mmo-client']);
|
|
190
|
+
expect(byDeployId['dd-fixture-a'].hosts).to.deep.equal(['server.fixture.test', 'client.fixture.test']);
|
|
191
|
+
expect(byDeployId['dd-fixture-b'].ids).to.deep.equal(['worker']);
|
|
192
|
+
expect(unmatched).to.deep.equal([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Expansion belongs to `run instance`, so the template id is handed over
|
|
196
|
+
// whole — but its variants' hosts are needed up front for the /etc/hosts pass.
|
|
197
|
+
it('passes a template id through while expanding its hosts', () => {
|
|
198
|
+
const { byDeployId } = clusterInstancesFactory(['dd-fixture-a'], 'mmo-server');
|
|
199
|
+
expect(byDeployId['dd-fixture-a'].ids).to.deep.equal(['mmo-server']);
|
|
200
|
+
expect(byDeployId['dd-fixture-a'].hosts).to.deep.equal(['server.fixture.test']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('selects a single variant by its concrete id', () => {
|
|
204
|
+
const { byDeployId, unmatched } = clusterInstancesFactory(['dd-fixture-a'], 'mmo-server-forest');
|
|
205
|
+
expect(byDeployId['dd-fixture-a'].ids).to.deep.equal(['mmo-server-forest']);
|
|
206
|
+
expect(unmatched).to.deep.equal([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('reports an id no deploy declares instead of silently skipping it', () => {
|
|
210
|
+
const { byDeployId, unmatched } = clusterInstancesFactory(['dd-fixture-a'], 'mmo-server+nope');
|
|
211
|
+
expect(byDeployId['dd-fixture-a'].ids).to.deep.equal(['mmo-server']);
|
|
212
|
+
expect(unmatched).to.deep.equal(['nope']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('is a no-op when no instance list is given', () => {
|
|
216
|
+
for (const list of ['', undefined, '+'])
|
|
217
|
+
expect(clusterInstancesFactory(['dd-fixture-a'], list)).to.deep.equal({
|
|
218
|
+
byDeployId: { 'dd-fixture-a': { ids: [], hosts: [] } },
|
|
219
|
+
unmatched: [],
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('treats a deploy with no conf.instances.json as having none', () => {
|
|
224
|
+
const { byDeployId, unmatched } = clusterInstancesFactory(['dd-fixture-no-such-deploy'], 'mmo-server');
|
|
225
|
+
expect(byDeployId['dd-fixture-no-such-deploy']).to.deep.equal({ ids: [], hosts: [] });
|
|
226
|
+
expect(unmatched).to.deep.equal(['mmo-server']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The deploy's Gateway terminates every hostname the deploy declares, and the
|
|
230
|
+
// environment provisions a certificate for each. The two sets are read from
|
|
231
|
+
// one resolver so they cannot drift: a hostname on the Gateway with no
|
|
232
|
+
// certificate leaves an unresolvable ref, which costs the whole listener.
|
|
233
|
+
describe('deploy hostnames', () => {
|
|
234
|
+
it('unions the server hosts with every instance host', () => {
|
|
235
|
+
expect(deployHostsFactory('dd-fixture-a')).to.deep.equal([
|
|
236
|
+
'app.fixture.test',
|
|
237
|
+
'server.fixture.test',
|
|
238
|
+
'client.fixture.test',
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Not just the ones being deployed now: the Gateway is per deploy, not per run.
|
|
243
|
+
it('returns a variant family once, however many variants it has', () => {
|
|
244
|
+
const hosts = deployHostsFactory('dd-fixture-a');
|
|
245
|
+
expect(hosts.filter((host) => host === 'server.fixture.test')).to.have.length(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('falls back to the server hosts when a deploy declares no instances', () => {
|
|
249
|
+
expect(deployHostsFactory('dd-fixture-no-such-deploy')).to.deep.equal([]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// A status page is built and versioned by the project its instance runs, so
|
|
254
|
+
// `hostPath` resolves against that project's checkout — not the engine root.
|
|
255
|
+
describe('instance status pages', () => {
|
|
256
|
+
it('roots hostPath at the project the instance runs', () => {
|
|
257
|
+
expect(instanceProjectPathFactory({ metadata: { repository: 'underpostnet/fixture-server' } })).to.equal(
|
|
258
|
+
'./fixture-server',
|
|
259
|
+
);
|
|
260
|
+
expect(instanceProjectPathFactory({ runtime: 'fixture-runtime' })).to.equal('./fixture-runtime');
|
|
261
|
+
expect(instanceProjectPathFactory({ id: 'bare' })).to.equal('./bare');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Every variant gets its own document, at the path its own rule rewrites to.
|
|
265
|
+
it('places one document per variant, where that variant is routed', () => {
|
|
266
|
+
const entries = instanceStatusPageEntriesFactory({ instances: loadConfInstances('dd-fixture-a') });
|
|
267
|
+
expect(entries.map((entry) => entry.assetPath)).to.deep.equal([
|
|
268
|
+
'server.fixture.test/root/status-pages/404/index.html',
|
|
269
|
+
'server.fixture.test/FOREST/status-pages/404/index.html',
|
|
270
|
+
]);
|
|
271
|
+
expect([...new Set(entries.map((entry) => entry.sourcePath))]).to.deep.equal([
|
|
272
|
+
'fixture-server/public/404/index.html',
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// The destination is the rewrite target, read from the same factory the
|
|
277
|
+
// HTTPRoute rule uses, so a document can never land where nothing routes.
|
|
278
|
+
it('agrees with the route rewrite target', () => {
|
|
279
|
+
for (const entry of instanceStatusPageEntriesFactory({ instances: loadConfInstances('dd-fixture-a') }))
|
|
280
|
+
expect(entry.assetPath).to.equal(
|
|
281
|
+
statusPageAssetPathFactory({ host: entry.host, path: entry.path, status: entry.status }).assetPath,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('honours an explicit project path', () => {
|
|
286
|
+
const [entry] = instanceStatusPageEntriesFactory({
|
|
287
|
+
instances: loadConfInstances('dd-fixture-a'),
|
|
288
|
+
projectPath: './elsewhere',
|
|
289
|
+
});
|
|
290
|
+
expect(entry.sourcePath).to.equal('elsewhere/public/404/index.html');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('skips instances and entries that declare no page', () => {
|
|
294
|
+
expect(instanceStatusPageEntriesFactory({ instances: loadConfInstances('dd-fixture-b') })).to.deep.equal([]);
|
|
295
|
+
expect(
|
|
296
|
+
instanceStatusPageEntriesFactory({
|
|
297
|
+
instances: [{ host: 'h', path: '/', customStatusPages: [{ status: '404' }, { hostPath: './x' }] }],
|
|
298
|
+
}),
|
|
299
|
+
).to.deep.equal([]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('uses the custom status document while the instance backend is unavailable', () => {
|
|
303
|
+
expect(instanceInterceptStatusesFactory(loadConfInstances('dd-fixture-a')[0])).to.deep.equal({
|
|
304
|
+
404: 'status-pages/404',
|
|
305
|
+
502: 'status-pages/404',
|
|
306
|
+
503: 'status-pages/404',
|
|
307
|
+
504: 'status-pages/404',
|
|
308
|
+
});
|
|
309
|
+
expect(instanceInterceptStatusesFactory(loadConfInstances('dd-fixture-b')[0])).to.deep.equal({});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('cluster gateway bootstrap order', () => {
|
|
314
|
+
const source = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
|
|
315
|
+
const clusterRunner = source.slice(source.indexOf('cluster: async'), source.indexOf("'gateway-status': async"));
|
|
316
|
+
|
|
317
|
+
it('tests the ingress-only fallback before applying any workload Deployment', () => {
|
|
318
|
+
const ingressOnly = clusterRunner.indexOf('--disable-update-deployment ${deployFlags}');
|
|
319
|
+
const checkpoint = clusterRunner.indexOf('gatewayFallbackProbeRunner({');
|
|
320
|
+
const workloadsOnly = clusterRunner.indexOf('--disable-update-proxy ${deployFlags}');
|
|
321
|
+
expect(ingressOnly).to.be.greaterThan(-1);
|
|
322
|
+
expect(checkpoint).to.be.greaterThan(ingressOnly);
|
|
323
|
+
expect(workloadsOnly).to.be.greaterThan(checkpoint);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('bootstraps instance routes before the no-backend checkpoint', () => {
|
|
327
|
+
const instanceGateway = clusterRunner.indexOf("RUNNERS['instance-promote']");
|
|
328
|
+
const checkpoint = clusterRunner.indexOf('gatewayFallbackProbeRunner({');
|
|
329
|
+
const instanceRuntime = clusterRunner.indexOf('RUNNERS.instance(');
|
|
330
|
+
expect(instanceGateway).to.be.greaterThan(-1);
|
|
331
|
+
expect(checkpoint).to.be.greaterThan(instanceGateway);
|
|
332
|
+
expect(instanceRuntime).to.be.greaterThan(checkpoint);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('enforces the same ingress/fallback/workload order in direct sync', () => {
|
|
336
|
+
const syncRunner = source.slice(source.indexOf('sync: async'), source.indexOf('stop: async'));
|
|
337
|
+
const ingressOnly = syncRunner.indexOf('--disable-update-deployment');
|
|
338
|
+
const checkpoint = syncRunner.indexOf('gatewayFallbackProbeRunner');
|
|
339
|
+
const workloadsOnly = syncRunner.indexOf('--disable-update-proxy');
|
|
340
|
+
expect(ingressOnly).to.be.greaterThan(-1);
|
|
341
|
+
expect(checkpoint).to.be.greaterThan(ingressOnly);
|
|
342
|
+
expect(workloadsOnly).to.be.greaterThan(checkpoint);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('proves direct instance fallback before rendering or applying deployment YAML', () => {
|
|
346
|
+
const instanceRunner = source.slice(source.indexOf('instance: async'), source.indexOf("'deploy-key':"));
|
|
347
|
+
const staticAssets = instanceRunner.indexOf('placeInstanceStaticAssets');
|
|
348
|
+
const instanceGateway = instanceRunner.indexOf("RUNNERS['instance-promote']");
|
|
349
|
+
const checkpoint = instanceRunner.indexOf('gatewayFallbackProbeRunner');
|
|
350
|
+
const deploymentYaml = instanceRunner.indexOf('let deploymentYaml');
|
|
351
|
+
expect(staticAssets).to.be.greaterThan(-1);
|
|
352
|
+
expect(instanceGateway).to.be.greaterThan(staticAssets);
|
|
353
|
+
expect(checkpoint).to.be.greaterThan(instanceGateway);
|
|
354
|
+
expect(deploymentYaml).to.be.greaterThan(checkpoint);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('marks cluster-invoked instances so they do not repeat the gateway probe', () => {
|
|
358
|
+
expect(clusterRunner.indexOf('gatewayBootstrapComplete: true')).to.be.greaterThan(-1);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// The Gateway API with QUIC/HTTP3 is the platform's stack; HTTPProxy is what a
|
|
363
|
+
// caller opts into. Every runner resolves it from one place, so none of them
|
|
364
|
+
// can default to a different stack than the one that deployed the routes.
|
|
365
|
+
describe('routing stack default', () => {
|
|
366
|
+
it('is on unless explicitly disabled', () => {
|
|
367
|
+
expect(gatewayApiEnabledFactory({})).to.equal(true);
|
|
368
|
+
expect(gatewayApiEnabledFactory({ dev: true })).to.equal(true);
|
|
369
|
+
expect(gatewayApiEnabledFactory({ gatewayApi: true })).to.equal(true);
|
|
370
|
+
expect(gatewayApiEnabledFactory({ disableGatewayApi: true })).to.equal(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// An explicit request is never second-guessed.
|
|
374
|
+
it('lets an explicit --gateway-api win over the opt-out', () => {
|
|
375
|
+
expect(gatewayApiEnabledFactory({ gatewayApi: true, disableGatewayApi: true })).to.equal(true);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// An in-process runner reads the cluster type from these flags alone. When
|
|
380
|
+
// none is set every consumer independently falls back to kind: the image pull
|
|
381
|
+
// shells into `kind-worker`, and hostPath `nodeAffinity` pins to that same
|
|
382
|
+
// non-existent node.
|
|
383
|
+
describe('cluster context', () => {
|
|
384
|
+
// Mirrors the three reads inside `run instance`.
|
|
385
|
+
const consumers = (options) => ({
|
|
386
|
+
pullsIntoKind: !!(options.kind || (!options.nodeName && !options.kubeadm && !options.k3s)),
|
|
387
|
+
resolvesKindNode: !!(options.kind || (!options.kubeadm && !options.k3s)),
|
|
388
|
+
volumeContext: clusterTypeFactory(options),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('carries each cluster type as exactly one flag', () => {
|
|
392
|
+
expect(clusterContextFactory('kubeadm')).to.deep.equal({ kind: false, kubeadm: true, k3s: false });
|
|
393
|
+
expect(clusterContextFactory('k3s')).to.deep.equal({ kind: false, kubeadm: false, k3s: true });
|
|
394
|
+
expect(clusterContextFactory('kind')).to.deep.equal({ kind: true, kubeadm: false, k3s: false });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('round-trips through the flags it reads back', () => {
|
|
398
|
+
for (const clusterType of ['kind', 'kubeadm', 'k3s'])
|
|
399
|
+
expect(clusterTypeFactory(clusterContextFactory(clusterType))).to.equal(clusterType);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// The cluster runner never provisions kind, so it names its own fallback.
|
|
403
|
+
it('falls back to the type the caller names', () => {
|
|
404
|
+
expect(clusterTypeFactory({})).to.equal('kind');
|
|
405
|
+
expect(clusterTypeFactory({}, 'kubeadm')).to.equal('kubeadm');
|
|
406
|
+
expect(clusterTypeFactory({ k3s: true }, 'kubeadm')).to.equal('k3s');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('takes every consumer off the kind default', () => {
|
|
410
|
+
expect(consumers({ dev: true })).to.deep.equal({
|
|
411
|
+
pullsIntoKind: true,
|
|
412
|
+
resolvesKindNode: true,
|
|
413
|
+
volumeContext: 'kind',
|
|
414
|
+
});
|
|
415
|
+
expect(consumers({ dev: true, ...clusterContextFactory('kubeadm') })).to.deep.equal({
|
|
416
|
+
pullsIntoKind: false,
|
|
417
|
+
resolvesKindNode: false,
|
|
418
|
+
volumeContext: 'kubeadm',
|
|
419
|
+
});
|
|
420
|
+
expect(consumers({ dev: true, ...clusterContextFactory('k3s') })).to.deep.equal({
|
|
421
|
+
pullsIntoKind: false,
|
|
422
|
+
resolvesKindNode: false,
|
|
423
|
+
volumeContext: 'k3s',
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('overrides an inherited flag rather than merging with it', () => {
|
|
428
|
+
expect({ kind: true, ...clusterContextFactory('kubeadm') }).to.deep.equal({
|
|
429
|
+
kind: false,
|
|
430
|
+
kubeadm: true,
|
|
431
|
+
k3s: false,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import Underpost from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const deploymentManifest = (nodeName = '') =>
|
|
8
|
+
Underpost.deploy.deploymentYamlPartsFactory({
|
|
9
|
+
deployId: 'dd-node-placement-test',
|
|
10
|
+
env: 'production',
|
|
11
|
+
suffix: 'green',
|
|
12
|
+
replicas: 1,
|
|
13
|
+
image: 'underpost/wp:test',
|
|
14
|
+
namespace: 'default',
|
|
15
|
+
cmd: ['true'],
|
|
16
|
+
readinessProbe: { tcpSocket: { port: 3032 } },
|
|
17
|
+
nodeName,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('deployment node placement', () => {
|
|
21
|
+
it('renders explicit workload placement in the original pod template', () => {
|
|
22
|
+
expect(deploymentManifest('hp-envy-iso-ram-rocky9')).to.include(
|
|
23
|
+
'nodeSelector:\n kubernetes.io/hostname: hp-envy-iso-ram-rocky9',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves scheduling unconstrained when no deployment node was requested', () => {
|
|
28
|
+
expect(deploymentManifest()).to.not.include('nodeSelector:');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not restart a controller after node-move patches its pod template', () => {
|
|
32
|
+
const source = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
|
|
33
|
+
const start = source.indexOf(" 'node-move':");
|
|
34
|
+
const end = source.indexOf('\n /**', start);
|
|
35
|
+
const runner = source.slice(start, end);
|
|
36
|
+
expect(runner).to.include('kubectl patch');
|
|
37
|
+
expect(runner).to.not.match(/shellExec\(`kubectl rollout restart/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('forwards --node-name into live and generated custom-instance manifests', () => {
|
|
41
|
+
const source = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
|
|
42
|
+
const placements = source.match(/nodeName: options\.nodeName\s*\? Underpost\.deploy\.resolveDeployNode/g) || [];
|
|
43
|
+
expect(placements).to.have.length(2);
|
|
44
|
+
});
|
|
45
|
+
});
|