underpost 3.2.80 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +1216 -168
- package/src/server/cri.js +70 -0
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- package/test/wireguard-edge.test.js +1177 -0
package/src/server/conf.js
CHANGED
|
@@ -18,9 +18,13 @@ import {
|
|
|
18
18
|
timer,
|
|
19
19
|
} from '../client/components/core/CommonJs.js';
|
|
20
20
|
import * as dir from 'path';
|
|
21
|
+
import net from 'net';
|
|
22
|
+
import crypto from 'crypto';
|
|
21
23
|
import colors from 'colors';
|
|
22
24
|
import { loggerFactory } from './logger.js';
|
|
25
|
+
import { writeEnv } from './environment.js';
|
|
23
26
|
import { shellExec } from './process.js';
|
|
27
|
+
import { UNDERPOST_GATEWAY, statusPageAssetPathFactory } from './underpost-gateway.js';
|
|
24
28
|
import { DefaultConf } from '../../conf.js';
|
|
25
29
|
import splitFile from 'split-file';
|
|
26
30
|
import Underpost from '../index.js';
|
|
@@ -42,6 +46,13 @@ const logger = loggerFactory(import.meta);
|
|
|
42
46
|
*/
|
|
43
47
|
const ENV_REF_PREFIX = 'env:';
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Default deploy ID used when no deploy ID is specified.
|
|
51
|
+
* @constant {string}
|
|
52
|
+
* @memberof ServerConfBuilder
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_DEPLOY_ID = 'dd-default';
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* Resolves a standardized context key from host/path descriptors.
|
|
47
58
|
* The key is used across DB, WS, mailer, and cache registries.
|
|
@@ -163,63 +174,6 @@ const getConfFolder = (deployId) => {
|
|
|
163
174
|
: `./engine-private/conf/${deployId}`;
|
|
164
175
|
};
|
|
165
176
|
|
|
166
|
-
/**
|
|
167
|
-
* Reads `engine-private/deploy/dd.cron` and returns the deploy-id string,
|
|
168
|
-
* or `null` if the file does not exist or is empty.
|
|
169
|
-
*
|
|
170
|
-
* @method cronDeployIdResolve
|
|
171
|
-
* @returns {string|null} The deploy-id from dd.cron, or null.
|
|
172
|
-
* @memberof ServerConfBuilder
|
|
173
|
-
*/
|
|
174
|
-
const cronDeployIdResolve = () => {
|
|
175
|
-
const cronDeployFile = './engine-private/deploy/dd.cron';
|
|
176
|
-
if (fs.existsSync(cronDeployFile)) {
|
|
177
|
-
const id = fs.readFileSync(cronDeployFile, 'utf8').trim();
|
|
178
|
-
return id || null;
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Loads the deployment-specific `.env` file referenced by `engine-private/deploy/dd.cron`
|
|
185
|
-
* into `process.env`. Uses `NODE_ENV` to select the environment variant
|
|
186
|
-
* (defaults to `production`).
|
|
187
|
-
*
|
|
188
|
-
* Safe to call multiple times; subsequent calls are no-ops once the env is loaded.
|
|
189
|
-
*
|
|
190
|
-
* @method loadCronDeployEnv
|
|
191
|
-
* @memberof ServerConfBuilder
|
|
192
|
-
*/
|
|
193
|
-
function loadCronDeployEnv() {
|
|
194
|
-
const envName = process.env.NODE_ENV || 'production';
|
|
195
|
-
|
|
196
|
-
// 1) Load dd.cron env (takes full precedence)
|
|
197
|
-
const cronDeployId = cronDeployIdResolve();
|
|
198
|
-
if (cronDeployId) {
|
|
199
|
-
const cronEnvPath = `./engine-private/conf/${cronDeployId}/.env.${envName}`;
|
|
200
|
-
if (fs.existsSync(cronEnvPath)) {
|
|
201
|
-
const cronEnv = dotenv.parse(fs.readFileSync(cronEnvPath, 'utf8'));
|
|
202
|
-
process.env = { ...process.env, ...cronEnv };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// 2) Load dd.router envs — only keys not already present
|
|
207
|
-
const routerDeployFile = './engine-private/deploy/dd.router';
|
|
208
|
-
if (fs.existsSync(routerDeployFile)) {
|
|
209
|
-
const routerIds = fs.readFileSync(routerDeployFile, 'utf8').trim().split(',');
|
|
210
|
-
for (const deployId of routerIds) {
|
|
211
|
-
const id = deployId.trim();
|
|
212
|
-
if (!id) continue;
|
|
213
|
-
const envPath = `./engine-private/conf/${id}/.env.${envName}`;
|
|
214
|
-
if (!fs.existsSync(envPath)) continue;
|
|
215
|
-
const env = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
|
|
216
|
-
for (const key of Object.keys(env)) {
|
|
217
|
-
if (!(key in process.env)) process.env[key] = env[key];
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
177
|
/**
|
|
224
178
|
* Resolves the full path to a specific configuration JSON file for a deploy ID.
|
|
225
179
|
* For `server` configs in development mode with a subConf, it will prefer the
|
|
@@ -277,13 +231,6 @@ const readConfJson = (deployId, confType, options = {}) => {
|
|
|
277
231
|
return parsed;
|
|
278
232
|
};
|
|
279
233
|
|
|
280
|
-
/**
|
|
281
|
-
* Default deploy ID used when no deploy ID is specified.
|
|
282
|
-
* @constant {string}
|
|
283
|
-
* @memberof ServerConfBuilder
|
|
284
|
-
*/
|
|
285
|
-
const DEFAULT_DEPLOY_ID = 'dd-default';
|
|
286
|
-
|
|
287
234
|
/**
|
|
288
235
|
* @class Config
|
|
289
236
|
* @description Manages the configuration of the server.
|
|
@@ -1379,41 +1326,54 @@ const splitFileFactory = async (name, _path) => {
|
|
|
1379
1326
|
};
|
|
1380
1327
|
|
|
1381
1328
|
/**
|
|
1382
|
-
* @method
|
|
1383
|
-
* @description
|
|
1384
|
-
*
|
|
1329
|
+
* @method resolveReplicaCount
|
|
1330
|
+
* @description Normalizes a CLI `--replicas` value to a positive integer, falling back to the
|
|
1331
|
+
* caller's default when unset or invalid. Deliberately does not clamp upward: a floor would
|
|
1332
|
+
* silently override an explicit, lower request, which is what made `--replicas 2` deploy three
|
|
1333
|
+
* MongoDB members. Single source of truth so every statefulset reads the flag the same way.
|
|
1334
|
+
* @param {string|number} input - Raw `--replicas` value.
|
|
1335
|
+
* @param {number} [fallback=1] - Count to use when input is absent or not a positive integer.
|
|
1336
|
+
* @returns {number} Effective replica count.
|
|
1385
1337
|
* @memberof ServerConfBuilder
|
|
1386
1338
|
*/
|
|
1387
|
-
const
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
silent: true,
|
|
1392
|
-
}).trim();
|
|
1393
|
-
|
|
1394
|
-
/**
|
|
1395
|
-
* @method getUnderpostRootPath
|
|
1396
|
-
* @description Gets the underpost root path.
|
|
1397
|
-
* @returns {string} - The underpost root path.
|
|
1398
|
-
* @memberof ServerConfBuilder
|
|
1399
|
-
*/
|
|
1400
|
-
const getUnderpostRootPath = () => `${getNpmRootPath()}/underpost`;
|
|
1339
|
+
const resolveReplicaCount = (input, fallback = 1) => {
|
|
1340
|
+
const parsed = Number.parseInt(input, 10);
|
|
1341
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
1342
|
+
};
|
|
1401
1343
|
|
|
1402
1344
|
/**
|
|
1403
|
-
* @method
|
|
1404
|
-
* @description
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1345
|
+
* @method generateSecurePassword
|
|
1346
|
+
* @description Generates a cryptographically secure password satisfying every validatePassword
|
|
1347
|
+
* constraint (lowercase, uppercase, digit, special character). Backed by `crypto.randomBytes`,
|
|
1348
|
+
* never `Math.random`, because these values become long-lived service credentials.
|
|
1349
|
+
* @param {number} [length=16] - Password length; values below 8 are raised to 8.
|
|
1350
|
+
* @returns {string} The generated password.
|
|
1407
1351
|
* @memberof ServerConfBuilder
|
|
1408
1352
|
*/
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
);
|
|
1353
|
+
const generateSecurePassword = (length = 16) => {
|
|
1354
|
+
const size = Math.max(8, length);
|
|
1355
|
+
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
|
1356
|
+
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
1357
|
+
const digits = '0123456789';
|
|
1358
|
+
const special = '@#$%^&*()_+';
|
|
1359
|
+
const all = lower + upper + digits + special;
|
|
1360
|
+
const buf = crypto.randomBytes(size + 4);
|
|
1361
|
+
// Guarantee at least one character from each required class
|
|
1362
|
+
const chars = [
|
|
1363
|
+
lower[buf[0] % lower.length],
|
|
1364
|
+
upper[buf[1] % upper.length],
|
|
1365
|
+
digits[buf[2] % digits.length],
|
|
1366
|
+
special[buf[3] % special.length],
|
|
1367
|
+
];
|
|
1368
|
+
for (let i = 4; i < size; i++) chars.push(all[buf[i] % all.length]);
|
|
1369
|
+
// Fisher-Yates shuffle using an independent random buffer
|
|
1370
|
+
const shuf = crypto.randomBytes(size);
|
|
1371
|
+
for (let i = chars.length - 1; i > 0; i--) {
|
|
1372
|
+
const j = shuf[i % shuf.length] % (i + 1);
|
|
1373
|
+
[chars[i], chars[j]] = [chars[j], chars[i]];
|
|
1374
|
+
}
|
|
1375
|
+
return chars.join('');
|
|
1376
|
+
};
|
|
1417
1377
|
|
|
1418
1378
|
/**
|
|
1419
1379
|
* @method buildCliDoc
|
|
@@ -1807,40 +1767,106 @@ const readConfInstances = (deployId) => {
|
|
|
1807
1767
|
|
|
1808
1768
|
/**
|
|
1809
1769
|
* @method loadInstanceTopology
|
|
1810
|
-
* @description Returns
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
* multi-instance entries share the same variant set, so this returns it from the
|
|
1814
|
-
* first entry that declares one.
|
|
1770
|
+
* @description Returns normalized variants describing the deploy's instance
|
|
1771
|
+
* topology, or `null` when the deploy is single-instance. The root path is the
|
|
1772
|
+
* default variant; no separate default code is declared.
|
|
1815
1773
|
* @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
|
|
1816
|
-
* @returns {?{
|
|
1774
|
+
* @returns {?{variants: Array<object>}} The topology, or `null`.
|
|
1817
1775
|
* @memberof ServerConfBuilder
|
|
1818
1776
|
*/
|
|
1819
1777
|
const loadInstanceTopology = (deployId) => {
|
|
1820
1778
|
for (const entry of readConfInstances(deployId)) {
|
|
1821
1779
|
const mi = entry.multiInstance;
|
|
1822
1780
|
if (mi && Array.isArray(mi.variants) && mi.variants.length > 0)
|
|
1823
|
-
return
|
|
1781
|
+
return normalizeInstanceTopology(mi, `${deployId}/${entry.id}`);
|
|
1824
1782
|
}
|
|
1825
1783
|
return null;
|
|
1826
1784
|
};
|
|
1827
1785
|
|
|
1828
1786
|
/**
|
|
1829
|
-
* @method
|
|
1830
|
-
* @description
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
* @param {string
|
|
1835
|
-
* @param {string}
|
|
1836
|
-
* @
|
|
1837
|
-
* @returns {?string} Resolved value, or `null` when the env has no entry.
|
|
1787
|
+
* @method normalizeInstanceTopology
|
|
1788
|
+
* @description Expands the compact `multiInstance.variants` path list into the
|
|
1789
|
+
* descriptors used by deploy tooling. `/` is always the default and keeps the
|
|
1790
|
+
* template workload id. `/FOREST` produces code `FOREST`, slug `/forest`, and
|
|
1791
|
+
* path `/FOREST`. A missing root entry is prepended automatically.
|
|
1792
|
+
* @param {{variants?: Array<string>}} spec - Multi-instance specification.
|
|
1793
|
+
* @param {string} [context] - Configuration location used in validation errors.
|
|
1794
|
+
* @returns {{variants: Array<{code:string,slug:string,path:string,isDefault:boolean}>}}
|
|
1838
1795
|
* @memberof ServerConfBuilder
|
|
1839
1796
|
*/
|
|
1840
|
-
const
|
|
1841
|
-
const
|
|
1842
|
-
if (
|
|
1843
|
-
|
|
1797
|
+
const normalizeInstanceTopology = (spec, context = 'multiInstance') => {
|
|
1798
|
+
const declaredPaths = spec?.variants;
|
|
1799
|
+
if (!Array.isArray(declaredPaths) || declaredPaths.length === 0) return { variants: [] };
|
|
1800
|
+
if (declaredPaths.some((path) => typeof path !== 'string'))
|
|
1801
|
+
throw new Error(`${context}: multiInstance.variants must contain only path strings`);
|
|
1802
|
+
if (new Set(declaredPaths).size !== declaredPaths.length)
|
|
1803
|
+
throw new Error(`${context}: multiInstance.variants contains a duplicate path`);
|
|
1804
|
+
const paths = ['/', ...declaredPaths.filter((path) => path !== '/')];
|
|
1805
|
+
|
|
1806
|
+
const variants = paths.map((path) => {
|
|
1807
|
+
if (path !== '/' && !/^\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(path))
|
|
1808
|
+
throw new Error(`${context}: invalid instance variant path "${path}"`);
|
|
1809
|
+
const code = path.slice(1);
|
|
1810
|
+
return { code, slug: path === '/' ? '' : path.toLowerCase(), path, isDefault: path === '/' };
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
const seen = new Set();
|
|
1814
|
+
for (const variant of variants) {
|
|
1815
|
+
if (seen.has(variant.slug)) throw new Error(`${context}: duplicate instance variant "${variant.path}"`);
|
|
1816
|
+
seen.add(variant.slug);
|
|
1817
|
+
}
|
|
1818
|
+
return { variants };
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* @method dispatchBuildInstanceEnv
|
|
1823
|
+
* @description Applies an optional deploy-specific env builder to a canonical
|
|
1824
|
+
* env object. Generic topology code never knows project env key names; callers
|
|
1825
|
+
* register builders by deploy id. The runner-owned container id is applied last.
|
|
1826
|
+
* @param {object} options - Dispatch context.
|
|
1827
|
+
* @param {string} options.deployId - Deployment id used to select a builder.
|
|
1828
|
+
* @param {object} options.instance - Expanded instance descriptor.
|
|
1829
|
+
* @param {string} options.environment - development or production.
|
|
1830
|
+
* @param {Object<string,string>} options.baseEnv - Parsed canonical env file.
|
|
1831
|
+
* @param {string} options.containerDeployId - Runner-derived deployment id.
|
|
1832
|
+
* @param {Object<string,Function>} [options.builders] - Deploy id to env builder registry.
|
|
1833
|
+
* @returns {Object<string,string>} Complete materialized env object.
|
|
1834
|
+
* @memberof ServerConfBuilder
|
|
1835
|
+
*/
|
|
1836
|
+
const dispatchBuildInstanceEnv = ({
|
|
1837
|
+
deployId,
|
|
1838
|
+
instance,
|
|
1839
|
+
environment,
|
|
1840
|
+
baseEnv = {},
|
|
1841
|
+
containerDeployId,
|
|
1842
|
+
builders = {},
|
|
1843
|
+
}) => {
|
|
1844
|
+
const builder = builders[deployId];
|
|
1845
|
+
const env = builder ? builder({ deployId, instance, environment, env: { ...baseEnv } }) : { ...baseEnv };
|
|
1846
|
+
if (!env || typeof env !== 'object' || Array.isArray(env))
|
|
1847
|
+
throw new TypeError(`dispatchBuildInstanceEnv: builder for "${deployId}" must return an env object`);
|
|
1848
|
+
return { ...env, CONTAINER_DEPLOY_ID: containerDeployId };
|
|
1849
|
+
};
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* Loads a deploy project's optional instance env builder by convention.
|
|
1853
|
+
* `dd-cyberia` resolves to `src/projects/cyberia/instance-data.js`, whose
|
|
1854
|
+
* public integration export is `buildInstanceEnv`. Missing modules mean the
|
|
1855
|
+
* canonical env is copied unchanged; malformed exports fail explicitly.
|
|
1856
|
+
* @param {string} deployId - Deployment id in `dd-<project>` form.
|
|
1857
|
+
* @returns {Promise<Function|null>} Project env builder, when provided.
|
|
1858
|
+
* @memberof ServerConfBuilder
|
|
1859
|
+
*/
|
|
1860
|
+
const loadProjectInstanceEnvBuilder = async (deployId) => {
|
|
1861
|
+
const match = /^dd-([a-z0-9][a-z0-9-]*)$/.exec(`${deployId || ''}`);
|
|
1862
|
+
if (!match) return null;
|
|
1863
|
+
const moduleUrl = new URL(`../projects/${match[1]}/instance-data.js`, import.meta.url);
|
|
1864
|
+
if (!fs.existsSync(moduleUrl)) return null;
|
|
1865
|
+
const projectModule = await import(moduleUrl.href);
|
|
1866
|
+
if (projectModule.buildInstanceEnv === undefined) return null;
|
|
1867
|
+
if (typeof projectModule.buildInstanceEnv !== 'function')
|
|
1868
|
+
throw new TypeError(`${moduleUrl.pathname}: buildInstanceEnv must be a function`);
|
|
1869
|
+
return projectModule.buildInstanceEnv;
|
|
1844
1870
|
};
|
|
1845
1871
|
|
|
1846
1872
|
/**
|
|
@@ -1851,67 +1877,46 @@ const resolveInstanceEnvValue = (value, env, tokens) => {
|
|
|
1851
1877
|
* A template entry is never deployed as-is once variants exist: each variant
|
|
1852
1878
|
* produces its own entry whose id, env file path, volume mount and
|
|
1853
1879
|
* container-status strings are derived by replacing the template id token
|
|
1854
|
-
* throughout. The variant
|
|
1855
|
-
*
|
|
1856
|
-
* multi-instance untouched.
|
|
1880
|
+
* throughout. The `/` variant keeps the template id verbatim, so pre-existing
|
|
1881
|
+
* deployments, PVCs and env directories survive multi-instance expansion.
|
|
1857
1882
|
*
|
|
1858
|
-
* Nothing here is application-specific:
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
* may be env-scoped objects.
|
|
1883
|
+
* Nothing here is application-specific: variants preserve their public path
|
|
1884
|
+
* through the ingress and the runtime owns that base-path contract.
|
|
1885
|
+
* Project-specific env behavior is delegated through
|
|
1886
|
+
* {@link dispatchBuildInstanceEnv} rather than encoded in topology configuration.
|
|
1863
1887
|
*
|
|
1864
|
-
* Every expanded entry carries
|
|
1865
|
-
* `instanceCode`
|
|
1866
|
-
* the template id acts on the whole family) and `instanceEnv` (the resolved env
|
|
1867
|
-
* keys to write, keyed by environment: `{ development, production }`, so a build
|
|
1868
|
-
* in either mode can emit a complete env directory).
|
|
1888
|
+
* Every expanded entry carries normalized metadata consumed by deploy runners:
|
|
1889
|
+
* `instanceCode`, `instanceSlug`, `isDefaultInstance`, and `templateId`.
|
|
1869
1890
|
*
|
|
1870
1891
|
* @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
|
|
1871
1892
|
* @returns {Array<object>} Expanded instance entries.
|
|
1872
1893
|
* @memberof ServerConfBuilder
|
|
1873
1894
|
*/
|
|
1874
|
-
const INSTANCE_ENVS = ['development', 'production'];
|
|
1875
|
-
|
|
1876
1895
|
const loadConfInstances = (deployId) => {
|
|
1877
1896
|
const expanded = [];
|
|
1878
1897
|
for (const entry of readConfInstances(deployId)) {
|
|
1879
1898
|
const spec = entry.multiInstance;
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
expanded.push(entry);
|
|
1899
|
+
if (!Array.isArray(spec?.variants) || spec.variants.length === 0) {
|
|
1900
|
+
expanded.push(entry.path ? entry : { ...entry, path: '/' });
|
|
1883
1901
|
continue;
|
|
1884
1902
|
}
|
|
1903
|
+
if (Object.hasOwn(spec, 'env'))
|
|
1904
|
+
throw new Error(
|
|
1905
|
+
`loadConfInstances: ${deployId}/${entry.id} uses removed multiInstance.env; ` +
|
|
1906
|
+
'move project-specific env logic to a dispatch env builder',
|
|
1907
|
+
);
|
|
1908
|
+
const topology = normalizeInstanceTopology(spec, `${deployId}/${entry.id}`);
|
|
1909
|
+
const variants = topology.variants;
|
|
1885
1910
|
for (const variant of variants) {
|
|
1886
|
-
const id = variant.
|
|
1887
|
-
const instance = variant.
|
|
1911
|
+
const id = variant.isDefault ? entry.id : `${entry.id}-${variant.slug.slice(1)}`;
|
|
1912
|
+
const instance = variant.isDefault ? JSON.parse(JSON.stringify(entry)) : deepReplaceToken(entry, entry.id, id);
|
|
1888
1913
|
delete instance.multiInstance;
|
|
1889
1914
|
instance.id = id;
|
|
1890
1915
|
instance.path = variant.path;
|
|
1891
1916
|
instance.instanceCode = variant.code;
|
|
1917
|
+
instance.instanceSlug = variant.slug;
|
|
1918
|
+
instance.isDefaultInstance = variant.isDefault;
|
|
1892
1919
|
instance.templateId = entry.id;
|
|
1893
|
-
|
|
1894
|
-
const tokens = {
|
|
1895
|
-
code: variant.code,
|
|
1896
|
-
slug: variant.slug || '',
|
|
1897
|
-
path: variant.path,
|
|
1898
|
-
id,
|
|
1899
|
-
default: spec.default || '',
|
|
1900
|
-
};
|
|
1901
|
-
// Resolve the declared env keys for both environments so a build in either
|
|
1902
|
-
// mode writes a complete env directory (development.env + production.env).
|
|
1903
|
-
instance.instanceEnv = Object.fromEntries(INSTANCE_ENVS.map((e) => [e, {}]));
|
|
1904
|
-
for (const [key, value] of Object.entries(spec.env || {}))
|
|
1905
|
-
for (const e of INSTANCE_ENVS) {
|
|
1906
|
-
const resolved = resolveInstanceEnvValue(value, e, tokens);
|
|
1907
|
-
if (resolved !== null) instance.instanceEnv[e][key] = resolved;
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
// A backend that serves its routes at the root knows nothing about the
|
|
1911
|
-
// variant prefix — it is selected by env instead. Strip the prefix at the
|
|
1912
|
-
// proxy so the runtime stays instance-agnostic.
|
|
1913
|
-
if (spec.stripPathPrefix && variant.path !== '/')
|
|
1914
|
-
instance.pathRewritePolicy = [{ prefix: variant.path, replacement: '/' }];
|
|
1915
1920
|
expanded.push(instance);
|
|
1916
1921
|
}
|
|
1917
1922
|
}
|
|
@@ -1931,13 +1936,952 @@ const loadConfInstances = (deployId) => {
|
|
|
1931
1936
|
const selectConfInstances = (instances, id) =>
|
|
1932
1937
|
instances.filter((instance) => instance.id === id || instance.templateId === id);
|
|
1933
1938
|
|
|
1939
|
+
/**
|
|
1940
|
+
* @method resolveEnvScoped
|
|
1941
|
+
* @description Resolves a conf value that may be declared either shared or
|
|
1942
|
+
* env-scoped. An instance block is written as `{ ...spec }` when both
|
|
1943
|
+
* environments share it and as `{ development: {...}, production: {...} }` when
|
|
1944
|
+
* they do not; both shapes reach the manifest factories, which expect the
|
|
1945
|
+
* resolved one.
|
|
1946
|
+
* @param {object|undefined} value - Shared or env-scoped block.
|
|
1947
|
+
* @param {string} env - `development` | `production`.
|
|
1948
|
+
* @returns {object|undefined} The block for `env`, or the value unchanged when it is shared.
|
|
1949
|
+
* @memberof ServerConfBuilder
|
|
1950
|
+
*/
|
|
1951
|
+
const resolveEnvScoped = (value, env) => (value && (value.development || value.production) ? value[env] : value);
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* @method instancePortFactory
|
|
1955
|
+
* @description The port an instance is reached on for an environment.
|
|
1956
|
+
* Development prefers the instance's debug port when it declares one, so a
|
|
1957
|
+
* local runtime can expose a debugger without the production port moving.
|
|
1958
|
+
* @param {object} instance - Expanded instance entry.
|
|
1959
|
+
* @param {string} env - `development` | `production`.
|
|
1960
|
+
* @param {boolean} [container] - Resolve the container-side port (`toPort`) instead of the proxied one (`fromPort`).
|
|
1961
|
+
* @returns {number|undefined} The effective port.
|
|
1962
|
+
* @memberof ServerConfBuilder
|
|
1963
|
+
*/
|
|
1964
|
+
const instancePortFactory = ({ instance, env, container = false }) => {
|
|
1965
|
+
const [port, debugPort] = container
|
|
1966
|
+
? [instance.toPort, instance.toDebugPort]
|
|
1967
|
+
: [instance.fromPort, instance.fromDebugPort];
|
|
1968
|
+
return env === 'development' && debugPort ? debugPort : port;
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* @method sortInstancesByPath
|
|
1973
|
+
* @description Orders instances longest sub-path first, so a specific instance
|
|
1974
|
+
* path (`/FOREST`) is never shadowed by the default instance's catch-all (`/`).
|
|
1975
|
+
* @param {Array<object>} instances - Expanded instance entries.
|
|
1976
|
+
* @returns {Array<object>} A new ordered array.
|
|
1977
|
+
* @memberof ServerConfBuilder
|
|
1978
|
+
*/
|
|
1979
|
+
const sortInstancesByPath = (instances) =>
|
|
1980
|
+
[...instances].sort((a, b) => (b.path || '/').length - (a.path || '/').length);
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* @method deployHostsFactory
|
|
1984
|
+
* @description Every hostname a deploy terminates: the ones its `conf.server.json`
|
|
1985
|
+
* serves directly, plus the ones its `conf.instances.json` instances serve.
|
|
1986
|
+
*
|
|
1987
|
+
* Both sets reach the browser through the one Gateway the deploy owns, so both
|
|
1988
|
+
* have to appear in its listener's certificate list and in the `/etc/hosts` pass
|
|
1989
|
+
* — an instance host missing from either is unreachable even though its workload
|
|
1990
|
+
* and route are correct.
|
|
1991
|
+
*
|
|
1992
|
+
* Every declared hostname is returned, not just the ones being deployed right
|
|
1993
|
+
* now: the Gateway is per deploy, not per run, and a certificate reference it
|
|
1994
|
+
* carries for a hostname the environment never provisioned is unresolvable —
|
|
1995
|
+
* which costs the listener, and with it every other hostname on it.
|
|
1996
|
+
* @param {string} deployId - Deployment identifier.
|
|
1997
|
+
* @returns {Array<string>} Unique hostnames, deploy hosts first.
|
|
1998
|
+
* @memberof ServerConfBuilder
|
|
1999
|
+
*/
|
|
2000
|
+
const deployHostsFactory = (deployId) => {
|
|
2001
|
+
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
|
|
2002
|
+
const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
|
|
2003
|
+
const serverHosts = fs.existsSync(confServerPath) ? Object.keys(loadConfServerJson(confServerPath)) : [];
|
|
2004
|
+
const instanceHosts = fs.existsSync(confInstancesPath)
|
|
2005
|
+
? loadConfInstances(deployId).map((instance) => instance.host)
|
|
2006
|
+
: [];
|
|
2007
|
+
return [...new Set([...serverHosts, ...instanceHosts].filter(Boolean))];
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* @method instanceStatusPageDeployIdFactory
|
|
2012
|
+
* @description Status page documents are identical across a family's variants,
|
|
2013
|
+
* so the resources holding them are named after the template instance and
|
|
2014
|
+
* shared by every variant route.
|
|
2015
|
+
* @param {string} deployId - Parent deployment identifier.
|
|
2016
|
+
* @param {object} instance - Expanded instance entry.
|
|
2017
|
+
* @returns {string} Family-scoped deploy id owning the status page resources.
|
|
2018
|
+
* @memberof ServerConfBuilder
|
|
2019
|
+
*/
|
|
2020
|
+
const instanceStatusPageDeployIdFactory = (deployId, instance) => `${deployId}-${instance.templateId || instance.id}`;
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* @method instanceProjectPathFactory
|
|
2024
|
+
* @description Where an instance's own project sits in this checkout. A
|
|
2025
|
+
* `customStatusPages` entry declares `hostPath` relative to that project
|
|
2026
|
+
* (`./public/404/index.html`), not to the engine root, because the document is
|
|
2027
|
+
* built and versioned by the project the instance runs.
|
|
2028
|
+
*
|
|
2029
|
+
* The directory is the repository's own name — the same name `underpost clone`
|
|
2030
|
+
* checks it out under — falling back to the runtime and then the instance id.
|
|
2031
|
+
* @param {object} instance - Expanded instance entry.
|
|
2032
|
+
* @returns {string} Project root, relative to the engine root.
|
|
2033
|
+
* @memberof ServerConfBuilder
|
|
2034
|
+
*/
|
|
2035
|
+
const instanceProjectPathFactory = (instance) =>
|
|
2036
|
+
`./${`${instance?.metadata?.repository || instance?.runtime || instance?.id || ''}`.split('/').pop()}`;
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* @method instanceStatusPageEntriesFactory
|
|
2040
|
+
* @description Resolves every `customStatusPages` entry an instance declares
|
|
2041
|
+
* into the document to copy and the place under the static root to copy it to.
|
|
2042
|
+
*
|
|
2043
|
+
* Single source of truth for the two sides that must agree exactly: the
|
|
2044
|
+
* destination comes from the same {@link UnderpostGateway.statusPageAssetPathFactory}
|
|
2045
|
+
* the HTTPRoute rewrites to, so a variant's page lands where that variant's rule
|
|
2046
|
+
* points — `/FOREST/404` at `<host>/FOREST/status-pages/404/index.html`, and the
|
|
2047
|
+
* default variant at `<host>/root/status-pages/404/index.html`.
|
|
2048
|
+
* @param {Array<object>} instances - Expanded instance entries.
|
|
2049
|
+
* @param {string} [projectPath] - Project root override; omit to derive one per instance.
|
|
2050
|
+
* @returns {Array<{host: string, path: string, status: string, assetPath: string, sourcePath: string}>}
|
|
2051
|
+
* One entry per declared page, skipping entries missing a status or a source.
|
|
2052
|
+
* @memberof ServerConfBuilder
|
|
2053
|
+
*/
|
|
2054
|
+
const instanceStatusPageEntriesFactory = ({ instances = [], projectPath }) =>
|
|
2055
|
+
instances.flatMap((instance) =>
|
|
2056
|
+
(instance.customStatusPages || [])
|
|
2057
|
+
.filter((page) => page?.status && page?.hostPath)
|
|
2058
|
+
.map((page) => ({
|
|
2059
|
+
host: instance.host,
|
|
2060
|
+
path: instance.path,
|
|
2061
|
+
status: `${page.status}`,
|
|
2062
|
+
assetPath: statusPageAssetPathFactory({ host: instance.host, path: instance.path, status: page.status })
|
|
2063
|
+
.assetPath,
|
|
2064
|
+
sourcePath: dir.normalize(`${projectPath || instanceProjectPathFactory(instance)}/${page.hostPath}`),
|
|
2065
|
+
})),
|
|
2066
|
+
);
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* @method nextTrafficFactory
|
|
2070
|
+
* @description The colour a promote routes to next.
|
|
2071
|
+
*
|
|
2072
|
+
* The single definition of the blue/green flip. An explicit request wins; with no
|
|
2073
|
+
* colour live yet the canonical first colour is `blue`.
|
|
2074
|
+
* @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
|
|
2075
|
+
* @param {string} [requestedTraffic] - Explicitly requested colour, if any.
|
|
2076
|
+
* @returns {string} `blue` or `green`.
|
|
2077
|
+
* @memberof ServerConfBuilder
|
|
2078
|
+
*/
|
|
2079
|
+
const nextTrafficFactory = (liveTraffic = '', requestedTraffic = '') =>
|
|
2080
|
+
requestedTraffic === 'blue' || requestedTraffic === 'green'
|
|
2081
|
+
? requestedTraffic
|
|
2082
|
+
: liveTraffic === 'blue'
|
|
2083
|
+
? 'green'
|
|
2084
|
+
: 'blue';
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* @method schedulableNodeFactory
|
|
2088
|
+
* @description Narrows a chosen node name to one the cluster actually has.
|
|
2089
|
+
*
|
|
2090
|
+
* Node defaults are guessed from the environment when no cluster flag is given —
|
|
2091
|
+
* `development` implies a kind cluster and therefore `kind-worker`. That guess is
|
|
2092
|
+
* wrong on a `--dev` kubeadm cluster, and for a `hostNetwork` listener pinned by
|
|
2093
|
+
* `nodeSelector` it is fatal rather than merely suboptimal: nothing schedules,
|
|
2094
|
+
* and the name persists in the live object so every later run inherits it.
|
|
2095
|
+
*
|
|
2096
|
+
* A control-plane node is the last resort, not the first: on a multi-node cluster
|
|
2097
|
+
* the public listener belongs on a worker. With no node list to check against the
|
|
2098
|
+
* caller's choice is returned untouched — an unreadable cluster is not evidence
|
|
2099
|
+
* the name is wrong.
|
|
2100
|
+
* @param {Array<object>} [nodes] - Rows from `kubectl get nodes` (NAME, STATUS, ROLES).
|
|
2101
|
+
* @param {string} [node] - The chosen node name.
|
|
2102
|
+
* @returns {{node: string, corrected: boolean}} The name to use, and whether it had to change.
|
|
2103
|
+
* @memberof ServerConfBuilder
|
|
2104
|
+
*/
|
|
2105
|
+
const schedulableNodeFactory = ({ nodes = [], node = '' }) => {
|
|
2106
|
+
const named = nodes.filter((entry) => entry?.NAME);
|
|
2107
|
+
if (named.length === 0) return { node, corrected: false };
|
|
2108
|
+
if (node && named.some((entry) => entry.NAME === node)) return { node, corrected: false };
|
|
2109
|
+
// STATUS can be a comma-joined list (e.g. "Ready,SchedulingDisabled").
|
|
2110
|
+
const ready = named.filter((entry) => `${entry.STATUS || ''}`.split(',').includes('Ready'));
|
|
2111
|
+
const pool = ready.length > 0 ? ready : named;
|
|
2112
|
+
const worker = pool.find((entry) => !`${entry.ROLES || ''}`.includes('control-plane'));
|
|
2113
|
+
return { node: (worker || pool[0]).NAME, corrected: true };
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* @method stopPlanFactory
|
|
2118
|
+
* @description Resolves which colour-suffixed Deployments a stop should remove.
|
|
2119
|
+
*
|
|
2120
|
+
* Four ways to name them, in precedence order:
|
|
2121
|
+
*
|
|
2122
|
+
* 1. A literal comma path names the Deployments outright and every flag is
|
|
2123
|
+
* ignored — the caller already knows the exact object, so nothing is derived
|
|
2124
|
+
* and nothing else can be caught by accident.
|
|
2125
|
+
* 2. `deployId` alone selects that deploy's PWA workload.
|
|
2126
|
+
* 3. `deployId` with `instanceId` adds every custom instance of each id; a
|
|
2127
|
+
* template id expands to its whole variant family, since each variant is its
|
|
2128
|
+
* own Deployment.
|
|
2129
|
+
* 4. `instanceId` without `deployId` is refused: an instance id is only unique
|
|
2130
|
+
* inside a deploy, so acting on it alone would be a guess.
|
|
2131
|
+
*
|
|
2132
|
+
* Colour selection is separate: `traffic` names the colours explicitly (a comma
|
|
2133
|
+
* list, so `blue,green` stops both), and without it each target resolves to the
|
|
2134
|
+
* blue/green partner of whatever it is currently serving — the colour that is by
|
|
2135
|
+
* definition not carrying traffic.
|
|
2136
|
+
* @param {string} [path] - Literal comma-separated Deployment names.
|
|
2137
|
+
* @param {string} [deployId] - Deploy id whose workload and instances are targeted.
|
|
2138
|
+
* @param {string} [instanceId] - Comma-separated instance or template ids.
|
|
2139
|
+
* @param {string} [traffic] - Comma-separated colours; empty means the inactive one.
|
|
2140
|
+
* @param {string} [env] - `development` | `production`.
|
|
2141
|
+
* @param {Function} [instancesFor] - `(instanceId) => Array<object>` expanded instances.
|
|
2142
|
+
* @param {Function} [liveTrafficOf] - `(target) => 'blue' | 'green' | '' | null`.
|
|
2143
|
+
* @returns {{deployments: Array<object>, error: string|null}} The plan, or why there isn't one.
|
|
2144
|
+
* @memberof ServerConfBuilder
|
|
2145
|
+
*/
|
|
2146
|
+
const stopPlanFactory = ({
|
|
2147
|
+
path = '',
|
|
2148
|
+
deployId = '',
|
|
2149
|
+
instanceId = '',
|
|
2150
|
+
traffic = '',
|
|
2151
|
+
env = '',
|
|
2152
|
+
instancesFor = () => [],
|
|
2153
|
+
liveTrafficOf = () => '',
|
|
2154
|
+
}) => {
|
|
2155
|
+
const list = (value) =>
|
|
2156
|
+
`${value || ''}`
|
|
2157
|
+
.split(',')
|
|
2158
|
+
.map((entry) => entry.trim())
|
|
2159
|
+
.filter(Boolean);
|
|
2160
|
+
|
|
2161
|
+
const literal = list(path);
|
|
2162
|
+
if (literal.length > 0)
|
|
2163
|
+
return {
|
|
2164
|
+
deployments: literal.map((deployment) => ({ deployment, kind: 'literal', id: deployment, host: '', colour: '' })),
|
|
2165
|
+
error: null,
|
|
2166
|
+
};
|
|
2167
|
+
|
|
2168
|
+
const instanceIds = list(instanceId);
|
|
2169
|
+
if (!deployId)
|
|
2170
|
+
return {
|
|
2171
|
+
deployments: [],
|
|
2172
|
+
error:
|
|
2173
|
+
instanceIds.length > 0
|
|
2174
|
+
? '--instance-id requires --deploy-id: an instance id is only unique inside a deploy'
|
|
2175
|
+
: 'nothing to stop: pass a literal deployment path, or --deploy-id',
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
const requestedRaw = list(traffic);
|
|
2179
|
+
const requested = requestedRaw.filter((colour) => colour === 'blue' || colour === 'green');
|
|
2180
|
+
if (requestedRaw.length > 0 && requested.length === 0)
|
|
2181
|
+
return { deployments: [], error: `--traffic accepts blue and/or green, got: ${requestedRaw.join(',')}` };
|
|
2182
|
+
|
|
2183
|
+
const targets = [{ id: deployId, host: '', kind: 'pwa' }];
|
|
2184
|
+
for (const id of instanceIds)
|
|
2185
|
+
for (const instance of instancesFor(id))
|
|
2186
|
+
targets.push({ id: `${deployId}-${instance.id}`, host: instance.host || '', kind: 'instance' });
|
|
2187
|
+
|
|
2188
|
+
const deployments = [];
|
|
2189
|
+
const seen = new Set();
|
|
2190
|
+
for (const target of targets)
|
|
2191
|
+
for (const colour of requested.length > 0 ? requested : [nextTrafficFactory(liveTrafficOf(target))]) {
|
|
2192
|
+
const deployment = `${target.id}-${env}-${colour}`;
|
|
2193
|
+
if (seen.has(deployment)) continue;
|
|
2194
|
+
seen.add(deployment);
|
|
2195
|
+
deployments.push({ ...target, colour, deployment });
|
|
2196
|
+
}
|
|
2197
|
+
return { deployments, error: null };
|
|
2198
|
+
};
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* @method trafficFromRoutingInfoFactory
|
|
2202
|
+
* @description Reads a deployment's live colour out of the routing text that
|
|
2203
|
+
* carries it.
|
|
2204
|
+
*
|
|
2205
|
+
* Legacy stacks name the backend Service `<deployId>-<env>-<colour>-service`;
|
|
2206
|
+
* the stable traffic Service names the same value in `spec.selector.app` without
|
|
2207
|
+
* the `-service` suffix. The colour therefore reads the same way during and
|
|
2208
|
+
* after migration. Kept pure and separate
|
|
2209
|
+
* from the read so one host's routing text can be matched against several
|
|
2210
|
+
* deployments and environments without fetching it again.
|
|
2211
|
+
*
|
|
2212
|
+
* With `env` given the match is anchored on the full `<deployId>-<env>-` prefix:
|
|
2213
|
+
* essential on a shared multi-instance host, where one object holds several
|
|
2214
|
+
* variants' routes on possibly different colours, and where an unanchored match
|
|
2215
|
+
* would return a sibling's answer. `dd-cyberia-mmo-server` never matches
|
|
2216
|
+
* `dd-cyberia-mmo-server-forest` this way.
|
|
2217
|
+
* @param {string} [info] - Routing text (route object YAML and/or Nginx block).
|
|
2218
|
+
* @param {string} deployId - Deployment identifier the colour is wanted for.
|
|
2219
|
+
* @param {string} [env] - `development` | `production`; omitted falls back to a whole-text match.
|
|
2220
|
+
* @returns {string|null} `blue`, `green`, or null when the text names neither.
|
|
2221
|
+
* @memberof ServerConfBuilder
|
|
2222
|
+
*/
|
|
2223
|
+
const trafficFromRoutingInfoFactory = ({ info = '', deployId = '', env = '' }) => {
|
|
2224
|
+
if (!`${info}`.trim()) return null;
|
|
2225
|
+
if (env) {
|
|
2226
|
+
const escaped = `${deployId}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2227
|
+
const match = `${info}`.match(new RegExp(`${escaped}-${env}-(blue|green)(?:-service)?(?:\\s|$)`));
|
|
2228
|
+
return match ? match[1] : null;
|
|
2229
|
+
}
|
|
2230
|
+
return `${info}`.match('blue') ? 'blue' : `${info}`.match('green') ? 'green' : null;
|
|
2231
|
+
};
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* @method trafficProbePathsFactory
|
|
2235
|
+
* @description The literal paths a conf.server.json route should be probed on,
|
|
2236
|
+
* matching the `replicas`/`singleReplica` convention `loadReplicas` expands for
|
|
2237
|
+
* a real build (see push-bundle/pull-bundle): a `singleReplica` route is never
|
|
2238
|
+
* itself served, so probing its canonical path always reads as unrouted; a
|
|
2239
|
+
* plain `replicas` route serves the canonical path in addition to each replica.
|
|
2240
|
+
* Read-only by design — a traffic report must not mutate conf.server.json as a
|
|
2241
|
+
* side effect of being read, unlike `loadReplicas`.
|
|
2242
|
+
* @param {object} [routeConf] - `confServer[host][path]` entry.
|
|
2243
|
+
* @param {string} routePath - The canonical path key.
|
|
2244
|
+
* @returns {Array<string>} Paths to probe for this route.
|
|
2245
|
+
* @memberof ServerConfBuilder
|
|
2246
|
+
*/
|
|
2247
|
+
const trafficProbePathsFactory = (routeConf = {}, routePath) => {
|
|
2248
|
+
const replicas = Array.isArray(routeConf.replicas) ? routeConf.replicas : [];
|
|
2249
|
+
if (replicas.length === 0) return [routePath];
|
|
2250
|
+
return routeConf.singleReplica ? replicas : [routePath, ...replicas];
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
/**
|
|
2254
|
+
* @method deployTrafficEntriesFactory
|
|
2255
|
+
* @description Every routable deployment a deploy id owns, of both kinds.
|
|
2256
|
+
*
|
|
2257
|
+
* The two kinds are named differently in the cluster and resolved from different
|
|
2258
|
+
* conf files, which is exactly why a colour report has to build them together:
|
|
2259
|
+
* the PWA workload is one Deployment per deploy id serving whatever hosts its
|
|
2260
|
+
* `conf.server.json` declares, while each expanded custom instance is its own
|
|
2261
|
+
* Deployment behind one host sub-path.
|
|
2262
|
+
* @param {string} deployId - Deployment identifier.
|
|
2263
|
+
* @param {string} env - `development` | `production`.
|
|
2264
|
+
* @returns {Array<{kind: string, deployId: string, id: string, host: string, path: string, deployment: string}>} One entry per routable deployment.
|
|
2265
|
+
* @memberof ServerConfBuilder
|
|
2266
|
+
*/
|
|
2267
|
+
const deployTrafficEntriesFactory = ({ deployId, env }) => {
|
|
2268
|
+
const entries = [];
|
|
2269
|
+
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
|
|
2270
|
+
if (fs.existsSync(confServerPath)) {
|
|
2271
|
+
const confServer = loadConfServerJson(confServerPath);
|
|
2272
|
+
for (const host of Object.keys(confServer))
|
|
2273
|
+
entries.push({
|
|
2274
|
+
kind: 'pwa',
|
|
2275
|
+
deployId,
|
|
2276
|
+
id: deployId,
|
|
2277
|
+
host,
|
|
2278
|
+
path:
|
|
2279
|
+
Object.keys(confServer[host])
|
|
2280
|
+
.flatMap((routePath) => trafficProbePathsFactory(confServer[host][routePath], routePath))
|
|
2281
|
+
.join(' ') || '/',
|
|
2282
|
+
deployment: `${deployId}-${env}`,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`))
|
|
2286
|
+
for (const instance of loadConfInstances(deployId))
|
|
2287
|
+
entries.push({
|
|
2288
|
+
kind: 'instance',
|
|
2289
|
+
deployId,
|
|
2290
|
+
id: `${deployId}-${instance.id}`,
|
|
2291
|
+
host: instance.host,
|
|
2292
|
+
path: instance.path || '/',
|
|
2293
|
+
deployment: `${deployId}-${instance.id}-${env}`,
|
|
2294
|
+
});
|
|
2295
|
+
return entries;
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* @method hostIngressFactsFactory
|
|
2300
|
+
* @description What the cluster's routing objects say about each hostname: which
|
|
2301
|
+
* kind describes it, whether it is served over TLS, and whether HTTP/3 is on.
|
|
2302
|
+
*
|
|
2303
|
+
* None of the three can be read from the route object alone. The kind is which
|
|
2304
|
+
* object exists; TLS lives on the Gateway's listener (an HTTPRoute never carries
|
|
2305
|
+
* it) or on an HTTPProxy's `virtualhost.tls`; and HTTP/3 is a `ClientTrafficPolicy`
|
|
2306
|
+
* targeting that Gateway. So the answer is a correlation across four kinds, done
|
|
2307
|
+
* once for the whole cluster rather than per row.
|
|
2308
|
+
*
|
|
2309
|
+
* A hostname described by both kinds is a leftover from switching stacks; the
|
|
2310
|
+
* HTTPRoute wins, matching the precedence the shared ingress routes it with.
|
|
2311
|
+
* @param {Array<object>} [httpRoutes] - HTTPRoute items.
|
|
2312
|
+
* @param {Array<object>} [httpProxies] - HTTPProxy items.
|
|
2313
|
+
* @param {Array<object>} [gateways] - Gateway items.
|
|
2314
|
+
* @param {Array<object>} [clientTrafficPolicies] - ClientTrafficPolicy items.
|
|
2315
|
+
* @returns {Object<string,{route: string, tls: boolean, http3: boolean}>} Facts by hostname.
|
|
2316
|
+
* @memberof ServerConfBuilder
|
|
2317
|
+
*/
|
|
2318
|
+
const hostIngressFactsFactory = ({
|
|
2319
|
+
httpRoutes = [],
|
|
2320
|
+
httpProxies = [],
|
|
2321
|
+
gateways = [],
|
|
2322
|
+
clientTrafficPolicies = [],
|
|
2323
|
+
} = {}) => {
|
|
2324
|
+
const tlsGateways = new Set(
|
|
2325
|
+
gateways
|
|
2326
|
+
.filter((gateway) =>
|
|
2327
|
+
(gateway?.spec?.listeners || []).some((listener) => `${listener?.protocol}`.toUpperCase() === 'HTTPS'),
|
|
2328
|
+
)
|
|
2329
|
+
.map((gateway) => gateway?.metadata?.name)
|
|
2330
|
+
.filter(Boolean),
|
|
2331
|
+
);
|
|
2332
|
+
// QUIC only exists where TLS does, so a policy naming a Gateway with no HTTPS
|
|
2333
|
+
// listener describes nothing — the same reason the policy is emitted scoped to
|
|
2334
|
+
// the HTTPS section in the first place.
|
|
2335
|
+
const http3Gateways = new Set(
|
|
2336
|
+
clientTrafficPolicies
|
|
2337
|
+
.filter((policy) => policy?.spec?.http3 !== undefined)
|
|
2338
|
+
.flatMap((policy) => [...(policy?.spec?.targetRefs || []), policy?.spec?.targetRef].filter(Boolean))
|
|
2339
|
+
.map((ref) => ref?.name)
|
|
2340
|
+
.filter((name) => name && tlsGateways.has(name)),
|
|
2341
|
+
);
|
|
2342
|
+
|
|
2343
|
+
const facts = {};
|
|
2344
|
+
for (const proxy of httpProxies) {
|
|
2345
|
+
const host = proxy?.spec?.virtualhost?.fqdn;
|
|
2346
|
+
if (!host) continue;
|
|
2347
|
+
facts[host] = { route: 'HTTPProxy', tls: !!proxy?.spec?.virtualhost?.tls, http3: false };
|
|
2348
|
+
}
|
|
2349
|
+
for (const route of httpRoutes) {
|
|
2350
|
+
const parents = (route?.spec?.parentRefs || []).map((ref) => ref?.name).filter(Boolean);
|
|
2351
|
+
const tls = parents.some((name) => tlsGateways.has(name));
|
|
2352
|
+
const http3 = parents.some((name) => http3Gateways.has(name));
|
|
2353
|
+
for (const host of route?.spec?.hostnames || []) if (host) facts[host] = { route: 'HTTPRoute', tls, http3 };
|
|
2354
|
+
}
|
|
2355
|
+
return facts;
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* @method curlStatusChainFactory
|
|
2360
|
+
* @description Extracts the response chain emitted by `curl -L -v -i -s`.
|
|
2361
|
+
* Verbose response lines are authoritative because `-i` can duplicate the same
|
|
2362
|
+
* headers on stdout. A write-out marker supplies the final code when curl did
|
|
2363
|
+
* not emit a verbose response (and `000` when no HTTP response was received).
|
|
2364
|
+
* CONNECT tunnel acknowledgements are transport setup, not host responses, and
|
|
2365
|
+
* are deliberately excluded from the displayed chain.
|
|
2366
|
+
* @param {string} [raw] - Combined curl stdout/stderr.
|
|
2367
|
+
* @returns {Array<string>} Ordered three-digit response codes.
|
|
2368
|
+
* @memberof ServerConfBuilder
|
|
2369
|
+
*/
|
|
2370
|
+
const curlStatusChainFactory = (raw = '') => {
|
|
2371
|
+
const text = `${raw || ''}`;
|
|
2372
|
+
const verbose = [...text.matchAll(/^< HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
|
|
2373
|
+
(match) => match[1],
|
|
2374
|
+
);
|
|
2375
|
+
const headers = [...text.matchAll(/^HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
|
|
2376
|
+
(match) => match[1],
|
|
2377
|
+
);
|
|
2378
|
+
const chain = verbose.length > 0 ? verbose : headers;
|
|
2379
|
+
const finalCode = /UNDERPOST_CURL_FINAL=([0-9]{3})/.exec(text)?.[1] || '';
|
|
2380
|
+
if (finalCode && finalCode !== '000' && chain[chain.length - 1] !== finalCode) chain.push(finalCode);
|
|
2381
|
+
if (chain.length === 0) chain.push(finalCode || '000');
|
|
2382
|
+
return chain;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* @method trafficTableRowsFactory
|
|
2387
|
+
* @description Resolves the live colour of each routable deployment, optionally
|
|
2388
|
+
* narrowed to a set of hosts.
|
|
2389
|
+
*
|
|
2390
|
+
* Cluster lookups are injected so the shaping stays a pure resolution over the
|
|
2391
|
+
* conf. An entry whose colour cannot be read is reported with an empty colour
|
|
2392
|
+
* rather than dropped — "no route published" is the answer, not an absence.
|
|
2393
|
+
* @param {Array<object>} [entries] - Entries from {@link ServerConfBuilder.deployTrafficEntriesFactory}.
|
|
2394
|
+
* @param {Array<string>} [hosts] - Hosts to report on; empty reports every host.
|
|
2395
|
+
* @param {Function} liveTrafficOf - `(entry) => 'blue' | 'green' | '' | null`.
|
|
2396
|
+
* @param {Function} servesTraffic - `(entry, colour) => boolean`.
|
|
2397
|
+
* @returns {Array<object>} Entries with `traffic` and `serving` resolved.
|
|
2398
|
+
* @memberof ServerConfBuilder
|
|
2399
|
+
*/
|
|
2400
|
+
const trafficTableRowsFactory = ({
|
|
2401
|
+
entries = [],
|
|
2402
|
+
hosts = [],
|
|
2403
|
+
liveTrafficOf = () => '',
|
|
2404
|
+
servesTraffic = () => false,
|
|
2405
|
+
}) => {
|
|
2406
|
+
const wanted = new Set(hosts.filter(Boolean));
|
|
2407
|
+
return entries
|
|
2408
|
+
.filter((entry) => wanted.size === 0 || wanted.has(entry.host))
|
|
2409
|
+
.map((entry) => {
|
|
2410
|
+
const traffic = liveTrafficOf(entry) || '';
|
|
2411
|
+
return {
|
|
2412
|
+
...entry,
|
|
2413
|
+
traffic,
|
|
2414
|
+
serving: isTrafficServingFactory({
|
|
2415
|
+
liveTraffic: traffic,
|
|
2416
|
+
hasReadyEndpoints: (colour) => servesTraffic(entry, colour),
|
|
2417
|
+
}),
|
|
2418
|
+
};
|
|
2419
|
+
});
|
|
2420
|
+
};
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* @method hostRenderInstancesFactory
|
|
2424
|
+
* @description The instances a shared host's routing must be rendered from.
|
|
2425
|
+
*
|
|
2426
|
+
* A host's Nginx server block and its HTTPRoute are single objects shared by
|
|
2427
|
+
* every variant on that host, so both are rewritten whole on every promote.
|
|
2428
|
+
* Rendering them from the declared set alone therefore deletes the routes of any
|
|
2429
|
+
* variant that is still deployed but no longer declared — silently taking down a
|
|
2430
|
+
* workload this deploy was never asked to touch.
|
|
2431
|
+
*
|
|
2432
|
+
* Each variant sub-path is its own deployment, so a variant loses its route only
|
|
2433
|
+
* once its workload is actually gone. `preserved` carries the descriptors last
|
|
2434
|
+
* published for this host; a declared entry always wins, since it is the current
|
|
2435
|
+
* truth for that id.
|
|
2436
|
+
* @param {Array<object>} [declared] - Instances the conf declares for this host.
|
|
2437
|
+
* @param {Array<object>} [preserved] - Instances last published for this host.
|
|
2438
|
+
* @param {Function} [isDeployed] - `(instance) => boolean`, true while its workload exists.
|
|
2439
|
+
* @returns {Array<object>} Declared entries, plus still-deployed preserved ones.
|
|
2440
|
+
* @memberof ServerConfBuilder
|
|
2441
|
+
*/
|
|
2442
|
+
const hostRenderInstancesFactory = ({ declared = [], preserved = [], isDeployed = () => false }) => {
|
|
2443
|
+
const declaredIds = new Set(declared.map((instance) => instance.id));
|
|
2444
|
+
return [
|
|
2445
|
+
...declared,
|
|
2446
|
+
...preserved.filter((instance) => instance?.id && !declaredIds.has(instance.id) && isDeployed(instance)),
|
|
2447
|
+
];
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* @method isTrafficServingFactory
|
|
2452
|
+
* @description Whether a routed colour is actually carrying traffic.
|
|
2453
|
+
*
|
|
2454
|
+
* The one gate every no-backend fallback checkpoint is conditional on, shared by
|
|
2455
|
+
* `run sync` and `run instance`. A colour is serving only when it is both routed
|
|
2456
|
+
* and still has a ready endpoint: a route can name a colour whose workload is
|
|
2457
|
+
* long gone, and taking a host offline to prove a fallback is only acceptable
|
|
2458
|
+
* when nothing is serving it.
|
|
2459
|
+
* @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
|
|
2460
|
+
* @param {Function} hasReadyEndpoints - `(colour) => boolean`.
|
|
2461
|
+
* @returns {boolean} True when that colour is routed and reachable.
|
|
2462
|
+
* @memberof ServerConfBuilder
|
|
2463
|
+
*/
|
|
2464
|
+
const isTrafficServingFactory = ({ liveTraffic = '', hasReadyEndpoints = () => false }) =>
|
|
2465
|
+
!!liveTraffic && hasReadyEndpoints(liveTraffic);
|
|
2466
|
+
|
|
2467
|
+
/**
|
|
2468
|
+
* @method instanceTrafficPlanFactory
|
|
2469
|
+
* @description Resolves, for each instance, the colour routed now and the colour
|
|
2470
|
+
* to route next, and which instances are actually serving on the live one.
|
|
2471
|
+
*
|
|
2472
|
+
* `serving` is the precondition for the no-backend fallback checkpoint, which
|
|
2473
|
+
* routes the edge at a colour that has no Deployment yet. That is correct on a
|
|
2474
|
+
* first bring-up and an outage on a re-deploy, and a routed colour is only real
|
|
2475
|
+
* traffic when it still has a ready endpoint — a route alone can name a colour
|
|
2476
|
+
* whose workload is long gone.
|
|
2477
|
+
*
|
|
2478
|
+
* Cluster lookups are injected so this stays a pure resolution over the conf.
|
|
2479
|
+
* @param {Array<object>} [instances] - Expanded instance entries.
|
|
2480
|
+
* @param {string} [requestedTraffic] - Explicitly requested colour, if any.
|
|
2481
|
+
* @param {Function} liveTrafficOf - `(instance) => 'blue' | 'green' | '' | null`.
|
|
2482
|
+
* @param {Function} servesTraffic - `(instance, colour) => boolean`, true when that colour has a ready endpoint.
|
|
2483
|
+
* @returns {{liveTrafficById: Object<string,string>, targetTrafficById: Object<string,string>, serving: Array<object>}} The plan.
|
|
2484
|
+
* @memberof ServerConfBuilder
|
|
2485
|
+
*/
|
|
2486
|
+
const instanceTrafficPlanFactory = ({
|
|
2487
|
+
instances = [],
|
|
2488
|
+
requestedTraffic = '',
|
|
2489
|
+
liveTrafficOf = () => '',
|
|
2490
|
+
servesTraffic = () => false,
|
|
2491
|
+
}) => {
|
|
2492
|
+
const liveTrafficById = {};
|
|
2493
|
+
const targetTrafficById = {};
|
|
2494
|
+
const serving = [];
|
|
2495
|
+
for (const instance of instances) {
|
|
2496
|
+
const liveTraffic = liveTrafficOf(instance) || '';
|
|
2497
|
+
liveTrafficById[instance.id] = liveTraffic;
|
|
2498
|
+
targetTrafficById[instance.id] = nextTrafficFactory(liveTraffic, requestedTraffic);
|
|
2499
|
+
if (isTrafficServingFactory({ liveTraffic, hasReadyEndpoints: (colour) => servesTraffic(instance, colour) }))
|
|
2500
|
+
serving.push(instance);
|
|
2501
|
+
}
|
|
2502
|
+
return { liveTrafficById, targetTrafficById, serving };
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
/**
|
|
2506
|
+
* @method instanceInterceptStatusesFactory
|
|
2507
|
+
* @description The statuses the gateway intercepts for one instance, and the
|
|
2508
|
+
* context directory each is answered from.
|
|
2509
|
+
*
|
|
2510
|
+
* Driven entirely by what the instance declares: every `customStatusPages` entry
|
|
2511
|
+
* answers its own status, and those declarations are also what upstream-failure
|
|
2512
|
+
* codes fall back to — a variant whose workload is gone should show the same page
|
|
2513
|
+
* as one that has no such route, because to the client they are the same thing.
|
|
2514
|
+
* @param {object} instance - Expanded instance entry.
|
|
2515
|
+
* @returns {Object<string,string>} Status code → context directory under the instance sub-path.
|
|
2516
|
+
* @memberof ServerConfBuilder
|
|
2517
|
+
*/
|
|
2518
|
+
const instanceInterceptStatusesFactory = (instance) => {
|
|
2519
|
+
const statuses = {};
|
|
2520
|
+
for (const page of instance?.customStatusPages || []) {
|
|
2521
|
+
if (!page?.status || !page?.hostPath) continue;
|
|
2522
|
+
const context = `status-pages/${page.status}`;
|
|
2523
|
+
statuses[page.status] = context;
|
|
2524
|
+
// Custom instances do not have the PWA's `maintenanceDefault` SSR view.
|
|
2525
|
+
// Their declared status document is therefore also the only useful answer
|
|
2526
|
+
// while the binary is absent or still becoming Ready. Nginx preserves the
|
|
2527
|
+
// original 502/503/504 code while substituting this document, so clients
|
|
2528
|
+
// can distinguish an unavailable runtime from the instance's own 404.
|
|
2529
|
+
for (const failureStatus of [502, 503, 504]) if (!statuses[failureStatus]) statuses[failureStatus] = context;
|
|
2530
|
+
}
|
|
2531
|
+
return statuses;
|
|
2532
|
+
};
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* @method instanceProxyRoutesFactory
|
|
2536
|
+
* @description Renders the Contour HTTPProxy route block for every instance
|
|
2537
|
+
* sharing a host.
|
|
2538
|
+
* @param {string} deployId - Parent deployment identifier.
|
|
2539
|
+
* @param {Array<object>} instances - Expanded instance entries bound to one host.
|
|
2540
|
+
* @param {string} env - `development` | `production`.
|
|
2541
|
+
* @param {Object<string,string>} trafficById - Instance id → traffic colour.
|
|
2542
|
+
* @returns {string} Concatenated route YAML.
|
|
2543
|
+
* @memberof ServerConfBuilder
|
|
2544
|
+
*/
|
|
2545
|
+
const instanceProxyRoutesFactory = ({ deployId, instances, env, trafficById }) =>
|
|
2546
|
+
sortInstancesByPath(instances)
|
|
2547
|
+
.map((instance) =>
|
|
2548
|
+
Underpost.deploy.deploymentYamlServiceFactory({
|
|
2549
|
+
path: instance.path,
|
|
2550
|
+
port: instancePortFactory({ instance, env }),
|
|
2551
|
+
serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
|
|
2552
|
+
pathRewritePolicy: instance.pathRewritePolicy,
|
|
2553
|
+
}),
|
|
2554
|
+
)
|
|
2555
|
+
.join('');
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* @method instanceHttpRouteRulesFactory
|
|
2559
|
+
* @description Renders the Gateway API rules for every instance sharing a host:
|
|
2560
|
+
* the workload rule for each instance sub-path, plus the edge-served status page
|
|
2561
|
+
* rules declared by that instance's `customStatusPages`.
|
|
2562
|
+
*
|
|
2563
|
+
* Variant paths are preserved by default, so the selected runtime receives the
|
|
2564
|
+
* same URL that the client requested. An explicit generic `pathRewritePolicy`
|
|
2565
|
+
* is still passed through for unrelated workloads that define one directly.
|
|
2566
|
+
* @param {string} deployId - Parent deployment identifier.
|
|
2567
|
+
* @param {Array<object>} instances - Expanded instance entries bound to one host.
|
|
2568
|
+
* @param {string} env - `development` | `production`.
|
|
2569
|
+
* @param {Object<string,string>} trafficById - Instance id → traffic colour.
|
|
2570
|
+
* @param {object} [options] - Runner options (namespace, gateway/QUIC settings).
|
|
2571
|
+
* @param {Array<string>} [servedStatuses] - Statuses whose document reached the static tree; undefined means all declared.
|
|
2572
|
+
* @returns {string} Concatenated rule YAML.
|
|
2573
|
+
* @memberof ServerConfBuilder
|
|
2574
|
+
*/
|
|
2575
|
+
const instanceHttpRouteRulesFactory = ({ deployId, instances, env, trafficById, options, servedStatuses }) => {
|
|
2576
|
+
const { http3, altSvc } = Underpost.deploy.gatewayApiConfigFactory(options);
|
|
2577
|
+
const sorted = sortInstancesByPath(instances);
|
|
2578
|
+
// The `/` status fallback is only emitted when no workload claims the root
|
|
2579
|
+
// path: two rules matching `/` would leave gateway precedence ambiguous.
|
|
2580
|
+
const rootClaimed = sorted.some((instance) => (instance.path || '/') === '/');
|
|
2581
|
+
let rules = '';
|
|
2582
|
+
for (const [i, instance] of sorted.entries()) {
|
|
2583
|
+
rules += Underpost.deploy.statusPageRouteRulesFactory({
|
|
2584
|
+
deployId: instanceStatusPageDeployIdFactory(deployId, instance),
|
|
2585
|
+
host: instance.host,
|
|
2586
|
+
basePath: instance.path,
|
|
2587
|
+
statusPages: instance.customStatusPages,
|
|
2588
|
+
altSvc: http3 ? altSvc : undefined,
|
|
2589
|
+
catchAll: !rootClaimed && i === sorted.length - 1,
|
|
2590
|
+
servedStatuses,
|
|
2591
|
+
});
|
|
2592
|
+
// An instance that declares a status page is reached through the shared
|
|
2593
|
+
// gateway, which proxies to its workload and intercepts the errors. One that
|
|
2594
|
+
// declares none is routed straight there, so the extra hop only exists where
|
|
2595
|
+
// it buys something. Any explicit generic `pathRewritePolicy` moves to the
|
|
2596
|
+
// gateway with the workload route.
|
|
2597
|
+
const intercepted = Object.keys(instanceInterceptStatusesFactory(instance)).length > 0;
|
|
2598
|
+
rules += Underpost.deploy.httpRouteRuleFactory({
|
|
2599
|
+
path: instance.path,
|
|
2600
|
+
...(intercepted
|
|
2601
|
+
? { serviceId: UNDERPOST_GATEWAY.serviceName, port: UNDERPOST_GATEWAY.port }
|
|
2602
|
+
: {
|
|
2603
|
+
port: instancePortFactory({ instance, env }),
|
|
2604
|
+
serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
|
|
2605
|
+
pathRewritePolicy: instance.pathRewritePolicy,
|
|
2606
|
+
}),
|
|
2607
|
+
altSvc: http3 ? altSvc : undefined,
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
return rules;
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* @method clusterTypeFactory
|
|
2615
|
+
* @description The cluster runtime a set of options selects, as the string every
|
|
2616
|
+
* command line and volume context spells it.
|
|
2617
|
+
* @param {object} [options] - Options carrying the cluster flags.
|
|
2618
|
+
* @param {string} [defaultType] - Type when no flag is set; `kind` everywhere except workflows that never provision one.
|
|
2619
|
+
* @returns {string} `k3s` | `kubeadm` | `kind`.
|
|
2620
|
+
* @memberof ServerConfBuilder
|
|
2621
|
+
*/
|
|
2622
|
+
const clusterTypeFactory = (options = {}, defaultType = 'kind') =>
|
|
2623
|
+
options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : defaultType;
|
|
2624
|
+
|
|
2625
|
+
/**
|
|
2626
|
+
* A row returned by {@link UnderpostKubectl.get}. Column names come from
|
|
2627
|
+
* `kubectl get -o wide`; Services expose their port column as `PORT(S)`.
|
|
2628
|
+
*
|
|
2629
|
+
* @typedef {Object<string, string|undefined>} ExposeKubernetesResource
|
|
2630
|
+
* @property {string} NAME - Kubernetes resource name.
|
|
2631
|
+
*/
|
|
2632
|
+
|
|
2633
|
+
/**
|
|
2634
|
+
* @method exposeTcpPortsFactory
|
|
2635
|
+
* @description Extracts TCP Service ports from a parsed
|
|
2636
|
+
* `kubectl get svc -o wide` row. NodePort suffixes are ignored, so
|
|
2637
|
+
* `8080:32080/TCP` resolves to Service port `8080`.
|
|
2638
|
+
* @param {ExposeKubernetesResource} resource - Parsed Kubernetes resource row.
|
|
2639
|
+
* @returns {number[]} Positive TCP Service ports in the order reported by kubectl.
|
|
2640
|
+
* @memberof ServerConfBuilder
|
|
2641
|
+
*/
|
|
2642
|
+
const exposeTcpPortsFactory = (resource) =>
|
|
2643
|
+
`${resource?.['PORT(S)'] || ''}`
|
|
2644
|
+
.split(',')
|
|
2645
|
+
.filter((port) => port.includes('/TCP'))
|
|
2646
|
+
.map((port) => parseInt(port.split(':')[0]))
|
|
2647
|
+
.filter((port) => Number.isInteger(port) && port > 0);
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* @method exposePathPartsFactory
|
|
2651
|
+
* @description Parses a comma-separated expose runner path into safe literal
|
|
2652
|
+
* Kubernetes name fragments. These are literal fragments, not regular
|
|
2653
|
+
* expressions or shell input.
|
|
2654
|
+
* @param {string} [path=''] - Comma-separated Service or Pod name fragments.
|
|
2655
|
+
* @returns {string[]} Trimmed, non-empty literal resource-name fragments.
|
|
2656
|
+
* @throws {Error} When no fragment is supplied or a fragment contains
|
|
2657
|
+
* characters outside `[a-zA-Z0-9._-]`.
|
|
2658
|
+
* @memberof ServerConfBuilder
|
|
2659
|
+
*/
|
|
2660
|
+
const exposePathPartsFactory = (path = '') => {
|
|
2661
|
+
const parts = `${path}`
|
|
2662
|
+
.split(',')
|
|
2663
|
+
.map((part) => part.trim())
|
|
2664
|
+
.filter(Boolean);
|
|
2665
|
+
if (parts.length === 0) throw new Error('Expose requires a Service or Pod name in path');
|
|
2666
|
+
if (parts.some((part) => !/^[a-zA-Z0-9._-]+$/.test(part)))
|
|
2667
|
+
throw new Error(`Invalid Kubernetes resource name match: ${path}`);
|
|
2668
|
+
return parts;
|
|
2669
|
+
};
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* @method exposePartialMatchesFactory
|
|
2673
|
+
* @description Selects every resource whose `NAME` contains any requested
|
|
2674
|
+
* literal path fragment. Results follow path-fragment order, with an exact name
|
|
2675
|
+
* before partial names in each group, then lexical name order. The input array
|
|
2676
|
+
* is not mutated.
|
|
2677
|
+
* @param {ExposeKubernetesResource[]} resources - Parsed Kubernetes resource rows.
|
|
2678
|
+
* @param {string[]} pathParts - Literal name fragments from {@link exposePathPartsFactory}.
|
|
2679
|
+
* @returns {ExposeKubernetesResource[]} Matching resource rows in deterministic order.
|
|
2680
|
+
* @memberof ServerConfBuilder
|
|
2681
|
+
*/
|
|
2682
|
+
const exposePartialMatchesFactory = (resources, pathParts) =>
|
|
2683
|
+
resources
|
|
2684
|
+
.filter(({ NAME }) => pathParts.some((part) => `${NAME || ''}`.includes(part)))
|
|
2685
|
+
.sort((a, b) => {
|
|
2686
|
+
const pathIndexA = pathParts.findIndex((part) => `${a.NAME || ''}`.includes(part));
|
|
2687
|
+
const pathIndexB = pathParts.findIndex((part) => `${b.NAME || ''}`.includes(part));
|
|
2688
|
+
const exactA = a.NAME === pathParts[pathIndexA] ? 0 : 1;
|
|
2689
|
+
const exactB = b.NAME === pathParts[pathIndexB] ? 0 : 1;
|
|
2690
|
+
return pathIndexA - pathIndexB || exactA - exactB || `${a.NAME}`.localeCompare(`${b.NAME}`);
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* @method exposePortListFactory
|
|
2695
|
+
* @description Parses and validates a comma-separated CLI port list.
|
|
2696
|
+
* @param {string|number} [value=''] - Comma-separated port values.
|
|
2697
|
+
* @param {string} [optionName='ports'] - Option name used in validation errors.
|
|
2698
|
+
* @returns {number[]} Ordered TCP ports, preserving their CLI indices.
|
|
2699
|
+
* @throws {Error} When an item is empty, non-integer, or outside `1..65535`.
|
|
2700
|
+
* @memberof ServerConfBuilder
|
|
2701
|
+
*/
|
|
2702
|
+
const exposePortListFactory = (value = '', optionName = 'ports') => {
|
|
2703
|
+
if (value === '' || value === undefined || value === null) return [];
|
|
2704
|
+
const values = `${value}`.split(',').map((port) => port.trim());
|
|
2705
|
+
if (values.some((port) => port === '')) throw new Error(`Invalid ${optionName}: ${value}`);
|
|
2706
|
+
const ports = values.map(Number);
|
|
2707
|
+
if (ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))
|
|
2708
|
+
throw new Error(`Invalid ${optionName}: ${value}`);
|
|
2709
|
+
return ports;
|
|
2710
|
+
};
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* A validated Kubernetes port-forward mapping.
|
|
2714
|
+
*
|
|
2715
|
+
* @typedef {Object} ExposePortMapping
|
|
2716
|
+
* @property {string} kindType - Kubernetes resource kind (`svc` or `pod`).
|
|
2717
|
+
* @property {string} name - Kubernetes resource name.
|
|
2718
|
+
* @property {number} localPort - Host-side listening port.
|
|
2719
|
+
* @property {number} remotePort - Service or container-side destination port.
|
|
2720
|
+
*/
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* @method exposePortPlanFactory
|
|
2724
|
+
* @description Builds a complete, collision-free port-forward plan. With more
|
|
2725
|
+
* than one matched resource, container and host port lists map by resource
|
|
2726
|
+
* index. With one resource, list items map pairwise to multiple ports.
|
|
2727
|
+
* @param {object} options - Port planning options.
|
|
2728
|
+
* @param {ExposeKubernetesResource[]} options.resources - Ordered matched resources.
|
|
2729
|
+
* @param {string} options.kindType - Kubernetes resource kind (`svc` or `pod`).
|
|
2730
|
+
* @param {number[]} [options.containerPorts=[]] - Explicit destination ports.
|
|
2731
|
+
* @param {number[]} [options.hostPorts=[]] - Explicit host listening ports.
|
|
2732
|
+
* @param {function(ExposeKubernetesResource): number[]} [options.portsOf=exposeTcpPortsFactory] - Declared-port resolver.
|
|
2733
|
+
* @returns {ExposePortMapping[]} Complete port-forward mappings.
|
|
2734
|
+
* @throws {Error} When list cardinality cannot map by resource/port index, no
|
|
2735
|
+
* destination port exists, an explicit host port repeats, or an automatic port
|
|
2736
|
+
* cannot fit inside `1..65535`.
|
|
2737
|
+
* @memberof ServerConfBuilder
|
|
2738
|
+
*/
|
|
2739
|
+
const exposePortPlanFactory = ({
|
|
2740
|
+
resources,
|
|
2741
|
+
kindType,
|
|
2742
|
+
containerPorts = [],
|
|
2743
|
+
hostPorts = [],
|
|
2744
|
+
portsOf = exposeTcpPortsFactory,
|
|
2745
|
+
}) => {
|
|
2746
|
+
const resourceCount = resources.length;
|
|
2747
|
+
const multipleResources = resourceCount > 1;
|
|
2748
|
+
if (multipleResources && containerPorts.length > 0 && containerPorts.length !== resourceCount)
|
|
2749
|
+
throw new Error(`--expose-container-ports requires ${resourceCount} ports for ${resourceCount} resources`);
|
|
2750
|
+
if (multipleResources && hostPorts.length > 0 && hostPorts.length !== resourceCount)
|
|
2751
|
+
throw new Error(`--expose-host-ports requires ${resourceCount} ports for ${resourceCount} resources`);
|
|
2752
|
+
|
|
2753
|
+
const portGroups = resources.map((resource, resourceIndex) => {
|
|
2754
|
+
const declaredPorts = [...new Set(portsOf(resource))];
|
|
2755
|
+
let remotePorts = [];
|
|
2756
|
+
if (containerPorts.length > 0) {
|
|
2757
|
+
remotePorts = multipleResources ? [containerPorts[resourceIndex]] : [...containerPorts];
|
|
2758
|
+
} else if (hostPorts.length > 0 && !multipleResources) {
|
|
2759
|
+
remotePorts = hostPorts.map((hp) => (declaredPorts.includes(hp) ? hp : null)).filter(Boolean);
|
|
2760
|
+
if (remotePorts.length !== hostPorts.length) {
|
|
2761
|
+
remotePorts = declaredPorts.slice(0, hostPorts.length);
|
|
2762
|
+
}
|
|
2763
|
+
} else {
|
|
2764
|
+
remotePorts = declaredPorts;
|
|
2765
|
+
}
|
|
2766
|
+
if (remotePorts.length === 0)
|
|
2767
|
+
throw new Error(`No declared TCP port for ${kindType}/${resource.NAME}; pass --expose-container-ports <ports>`);
|
|
2768
|
+
const localPorts = hostPorts.length ? (multipleResources ? [hostPorts[resourceIndex]] : [...hostPorts]) : [];
|
|
2769
|
+
if (localPorts.length > 0 && localPorts.length !== remotePorts.length)
|
|
2770
|
+
throw new Error(
|
|
2771
|
+
`Host/container port counts differ for ${kindType}/${resource.NAME}: ${localPorts.length}/${remotePorts.length}`,
|
|
2772
|
+
);
|
|
2773
|
+
return { resource, remotePorts, localPorts };
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
const plan = [];
|
|
2777
|
+
const usedLocalPorts = new Set();
|
|
2778
|
+
for (const { resource, remotePorts, localPorts } of portGroups)
|
|
2779
|
+
for (const [portIndex, remotePort] of remotePorts.entries()) {
|
|
2780
|
+
const explicitLocalPort = localPorts[portIndex];
|
|
2781
|
+
let localPort = explicitLocalPort || remotePort;
|
|
2782
|
+
if (explicitLocalPort && usedLocalPorts.has(localPort))
|
|
2783
|
+
throw new Error(`Duplicate --expose-host-ports value: ${localPort}`);
|
|
2784
|
+
while (!explicitLocalPort && usedLocalPorts.has(localPort)) localPort++;
|
|
2785
|
+
if (localPort > 65535) throw new Error(`No valid host port remains for ${kindType}/${resource.NAME}`);
|
|
2786
|
+
usedLocalPorts.add(localPort);
|
|
2787
|
+
plan.push({ kindType, name: resource.NAME, localPort, remotePort });
|
|
2788
|
+
}
|
|
2789
|
+
return plan;
|
|
2790
|
+
};
|
|
2791
|
+
/**
|
|
2792
|
+
* @method gatewayApiEnabledFactory
|
|
2793
|
+
* @description Whether a workflow routes through the Gateway API stack.
|
|
2794
|
+
*
|
|
2795
|
+
* On unless explicitly disabled, in every runner: the Gateway API with QUIC/HTTP3
|
|
2796
|
+
* is the platform's routing stack, and the Contour HTTPProxy set is the fallback
|
|
2797
|
+
* a caller opts into with `--disable-gateway-api`. `--gateway-api` stays
|
|
2798
|
+
* meaningful as an explicit request, so a caller that passes it is never
|
|
2799
|
+
* second-guessed. Reading it from one place is what keeps a runner from
|
|
2800
|
+
* defaulting to a different stack than the one that deployed the routes.
|
|
2801
|
+
* @param {object} [options] - Runner/deploy options.
|
|
2802
|
+
* @returns {boolean} True when the Gateway API stack is in effect.
|
|
2803
|
+
* @memberof ServerConfBuilder
|
|
2804
|
+
*/
|
|
2805
|
+
const gatewayApiEnabledFactory = (options = {}) => options.gatewayApi === true || options.disableGatewayApi !== true;
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* @method clusterContextFactory
|
|
2809
|
+
* @description The inverse of {@link ServerConfBuilder.clusterTypeFactory}: a
|
|
2810
|
+
* cluster type as the option flags a runner reads.
|
|
2811
|
+
*
|
|
2812
|
+
* A workflow resolves its cluster type once and passes it to spawned commands as
|
|
2813
|
+
* `--${clusterType}`. A runner invoked in-process gets no such string — it sees
|
|
2814
|
+
* the raw options, where every consumer independently defaults to kind: the
|
|
2815
|
+
* image pull (`docker exec kind-worker`), the node resolution behind hostPath
|
|
2816
|
+
* `nodeAffinity`, and the volume cluster context. This carries the choice across
|
|
2817
|
+
* that boundary.
|
|
2818
|
+
* @param {string} clusterType - `kubeadm` | `k3s` | `kind`.
|
|
2819
|
+
* @returns {{kind: boolean, kubeadm: boolean, k3s: boolean}} Mutually exclusive context flags.
|
|
2820
|
+
* @memberof ServerConfBuilder
|
|
2821
|
+
*/
|
|
2822
|
+
const clusterContextFactory = (clusterType) => ({
|
|
2823
|
+
kind: clusterType === 'kind',
|
|
2824
|
+
kubeadm: clusterType === 'kubeadm',
|
|
2825
|
+
k3s: clusterType === 'k3s',
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* @method waitForPort
|
|
2830
|
+
* @description Polls a TCP port until it reaches the wanted state.
|
|
2831
|
+
*
|
|
2832
|
+
* Single source of truth for every "is it listening yet" wait: a port-forward
|
|
2833
|
+
* coming up locally, a freshly provisioned node's sshd, and the closed edge that
|
|
2834
|
+
* proves a reboot actually started. The probe is a native connect rather than a
|
|
2835
|
+
* shelled-out one, so it needs neither a shell nor `timeout` on the host and
|
|
2836
|
+
* reports the same result on every platform.
|
|
2837
|
+
* @param {number} port - Port to probe.
|
|
2838
|
+
* @param {string} [host] - Host to probe.
|
|
2839
|
+
* @param {boolean} [open] - Wanted state: true waits for the port to accept, false waits for it to refuse.
|
|
2840
|
+
* @param {number} [timeoutMs] - Maximum wait window.
|
|
2841
|
+
* @param {number} [intervalMs] - Delay between attempts.
|
|
2842
|
+
* @param {number} [connectTimeoutMs] - Per-attempt connect timeout.
|
|
2843
|
+
* @returns {Promise<boolean>} True once the wanted state is observed, false on timeout.
|
|
2844
|
+
* @memberof ServerConfBuilder
|
|
2845
|
+
*/
|
|
2846
|
+
const waitForPort = async ({
|
|
2847
|
+
port,
|
|
2848
|
+
host = '127.0.0.1',
|
|
2849
|
+
open = true,
|
|
2850
|
+
timeoutMs = 60 * 1000,
|
|
2851
|
+
intervalMs = 2000,
|
|
2852
|
+
connectTimeoutMs = 5000,
|
|
2853
|
+
}) => {
|
|
2854
|
+
const probe = () =>
|
|
2855
|
+
new Promise((resolve) => {
|
|
2856
|
+
const socket = new net.Socket();
|
|
2857
|
+
const done = (reachable) => {
|
|
2858
|
+
socket.destroy();
|
|
2859
|
+
resolve(reachable);
|
|
2860
|
+
};
|
|
2861
|
+
socket.setTimeout(connectTimeoutMs);
|
|
2862
|
+
socket.once('connect', () => done(true));
|
|
2863
|
+
socket.once('timeout', () => done(false));
|
|
2864
|
+
socket.once('error', () => done(false));
|
|
2865
|
+
socket.connect(port, host);
|
|
2866
|
+
});
|
|
2867
|
+
const deadline = Date.now() + timeoutMs;
|
|
2868
|
+
while (Date.now() < deadline) {
|
|
2869
|
+
if ((await probe()) === open) return true;
|
|
2870
|
+
await timer(intervalMs);
|
|
2871
|
+
}
|
|
2872
|
+
logger.warn(`Port ${host}:${port} was not ${open ? 'reachable' : 'closed'} within timeout`, { timeoutMs });
|
|
2873
|
+
return false;
|
|
2874
|
+
};
|
|
2875
|
+
|
|
1934
2876
|
/**
|
|
1935
2877
|
* Creates and writes the /etc/hosts file for a deployment.
|
|
1936
2878
|
* @method etcHostFactory
|
|
1937
2879
|
* @param {Array<string>} hosts - List of hosts to be added to the hosts file.
|
|
1938
2880
|
* @param {object} options - Options for the hosts file creation.
|
|
1939
2881
|
* @param {boolean} options.append - Whether to append to the existing hosts file.
|
|
1940
|
-
* @
|
|
2882
|
+
* @param {string} [options.blockId] - Replace an idempotent owned block while preserving unrelated entries.
|
|
2883
|
+
* @param {string} [options.path=/etc/hosts] - Hosts file path; injectable for tests.
|
|
2884
|
+
* @returns {{renderHosts: string, changed: boolean}} Rendered content and whether the file changed.
|
|
1941
2885
|
* @memberof ServerConfBuilder
|
|
1942
2886
|
*/
|
|
1943
2887
|
const etcHostFactory = (hosts = [], options = { append: false }) => {
|
|
@@ -1957,23 +2901,43 @@ const etcHostFactory = (hosts = [], options = { append: false }) => {
|
|
|
1957
2901
|
)} localhost localhost.localdomain localhost4 localhost4.localdomain4
|
|
1958
2902
|
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6`;
|
|
1959
2903
|
|
|
1960
|
-
|
|
2904
|
+
const hostsPath = options?.path || '/etc/hosts';
|
|
2905
|
+
if (options?.blockId) {
|
|
2906
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.blockId)) throw new Error(`Invalid /etc/hosts block id: ${options.blockId}`);
|
|
2907
|
+
const beginMarker = `# underpost hosts ${options.blockId}:begin`;
|
|
2908
|
+
const endMarker = `# underpost hosts ${options.blockId}:end`;
|
|
2909
|
+
const existing = fs.existsSync(hostsPath) ? fs.readFileSync(hostsPath, 'utf8') : '';
|
|
2910
|
+
const begin = existing.indexOf(beginMarker);
|
|
2911
|
+
const end = begin === -1 ? -1 : existing.indexOf(endMarker, begin);
|
|
2912
|
+
let outsideBlock = existing;
|
|
2913
|
+
if (begin !== -1)
|
|
2914
|
+
outsideBlock = `${existing.slice(0, begin)}${end === -1 ? '' : existing.slice(end + endMarker.length)}`;
|
|
2915
|
+
outsideBlock = outsideBlock.trimEnd();
|
|
2916
|
+
const updated = `${outsideBlock}${outsideBlock ? '\n' : ''}${beginMarker}\n${renderHosts}\n${endMarker}\n`;
|
|
2917
|
+
const changed = updated !== existing;
|
|
2918
|
+
if (changed) fs.writeFileSync(hostsPath, updated, 'utf8');
|
|
2919
|
+
return { renderHosts, changed };
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
if (options && options.append && fs.existsSync(hostsPath)) {
|
|
1961
2923
|
fs.writeFileSync(
|
|
1962
|
-
|
|
1963
|
-
fs.readFileSync(
|
|
2924
|
+
hostsPath,
|
|
2925
|
+
fs.readFileSync(hostsPath, 'utf8') +
|
|
1964
2926
|
`
|
|
1965
2927
|
${renderHosts}`,
|
|
1966
2928
|
'utf8',
|
|
1967
2929
|
);
|
|
1968
|
-
} else fs.writeFileSync(
|
|
1969
|
-
return { renderHosts };
|
|
2930
|
+
} else fs.writeFileSync(hostsPath, renderHosts, 'utf8');
|
|
2931
|
+
return { renderHosts, changed: true };
|
|
1970
2932
|
};
|
|
1971
2933
|
|
|
1972
2934
|
/**
|
|
1973
2935
|
* Resolves the concrete deploy ids a build or conf-sync run should iterate over.
|
|
1974
2936
|
*
|
|
1975
2937
|
* The meta deploy id `dd` fans out to the comma separated ids declared in
|
|
1976
|
-
* `engine-private/deploy/dd.router`;
|
|
2938
|
+
* `engine-private/deploy/dd.router`; when that file is absent (e.g. the private
|
|
2939
|
+
* repository is not checked out) it falls back to {@link ServerConfBuilder.DEFAULT_DEPLOY_ID}.
|
|
2940
|
+
* Any other value is parsed as a comma separated list.
|
|
1977
2941
|
* Entries are trimmed and empties dropped.
|
|
1978
2942
|
*
|
|
1979
2943
|
* @method resolveDeployList
|
|
@@ -1982,7 +2946,12 @@ ${renderHosts}`,
|
|
|
1982
2946
|
* @memberof ServerConfBuilder
|
|
1983
2947
|
*/
|
|
1984
2948
|
const resolveDeployList = (deployId) =>
|
|
1985
|
-
(deployId === 'dd'
|
|
2949
|
+
(deployId === 'dd'
|
|
2950
|
+
? fs.existsSync('./engine-private/deploy/dd.router')
|
|
2951
|
+
? fs.readFileSync('./engine-private/deploy/dd.router', 'utf8')
|
|
2952
|
+
: DEFAULT_DEPLOY_ID
|
|
2953
|
+
: deployId
|
|
2954
|
+
)
|
|
1986
2955
|
.split(',')
|
|
1987
2956
|
.map((id) => id.trim())
|
|
1988
2957
|
.filter(Boolean);
|
|
@@ -2131,6 +3100,7 @@ const buildTemplate = async ({ srcPath = './', toPath = '../pwa-microservices-te
|
|
|
2131
3100
|
}
|
|
2132
3101
|
shellExec(`rm -rf ${toPath}/.github`);
|
|
2133
3102
|
shellExec(`rm -rf ${toPath}/manifests/deployment/dd-*`);
|
|
3103
|
+
shellExec(`rm -rf ${toPath}/deploy`);
|
|
2134
3104
|
|
|
2135
3105
|
fs.mkdirSync(`${toPath}/.github/workflows`, { recursive: true });
|
|
2136
3106
|
for (const restorePath of TEMPLATE_RESTORE_PATHS) {
|
|
@@ -2256,10 +3226,59 @@ git add .`);
|
|
|
2256
3226
|
else logger.info('No changes to publish', { repoName });
|
|
2257
3227
|
};
|
|
2258
3228
|
|
|
3229
|
+
/**
|
|
3230
|
+
* @function clusterInstancesFactory
|
|
3231
|
+
* @description Binds the instance ids requested by the `cluster` runner to the
|
|
3232
|
+
* deploys that actually declare them.
|
|
3233
|
+
*
|
|
3234
|
+
* An instance belongs to a deploy through that deploy's own
|
|
3235
|
+
* `conf.instances.json` — nothing else relates the two — so the same id under a
|
|
3236
|
+
* different deploy is a different workload, and an id no deploy declares is a
|
|
3237
|
+
* typo rather than a silent no-op. A deploy without the file simply has no
|
|
3238
|
+
* instances.
|
|
3239
|
+
*
|
|
3240
|
+
* Ids are returned as requested, not expanded: `run instance` owns variant
|
|
3241
|
+
* expansion, so a template id (`mmo-server`) is handed over whole and deploys
|
|
3242
|
+
* its whole family. Hosts *are* expanded, because they are needed before any
|
|
3243
|
+
* instance runs — `/etc/hosts` is written in one pass for every host the
|
|
3244
|
+
* gateway will serve.
|
|
3245
|
+
* @param {Array<string>} deployList - Deploy ids being brought up.
|
|
3246
|
+
* @param {string} [instanceList] - `+`-separated instance/template ids.
|
|
3247
|
+
* @returns {{ byDeployId: Object<string,{ids: Array<string>, hosts: Array<string>}>, unmatched: Array<string> }}
|
|
3248
|
+
* Per-deploy selection, and the requested ids no deploy declares.
|
|
3249
|
+
*/
|
|
3250
|
+
const clusterInstancesFactory = (deployList = [], instanceList = '') => {
|
|
3251
|
+
const requested = `${instanceList || ''}`.split('+').filter((id) => id.trim());
|
|
3252
|
+
const byDeployId = Object.fromEntries(
|
|
3253
|
+
deployList.map((deployId) => {
|
|
3254
|
+
const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
|
|
3255
|
+
if (requested.length === 0 || !fs.existsSync(confPath)) return [deployId, { ids: [], hosts: [] }];
|
|
3256
|
+
const confInstances = loadConfInstances(deployId);
|
|
3257
|
+
const matched = requested
|
|
3258
|
+
.map((id) => ({ id, instances: selectConfInstances(confInstances, id) }))
|
|
3259
|
+
.filter((entry) => entry.instances.length > 0);
|
|
3260
|
+
return [
|
|
3261
|
+
deployId,
|
|
3262
|
+
{
|
|
3263
|
+
ids: matched.map((entry) => entry.id),
|
|
3264
|
+
hosts: [...new Set(matched.flatMap((entry) => entry.instances.map((instance) => instance.host)))],
|
|
3265
|
+
},
|
|
3266
|
+
];
|
|
3267
|
+
}),
|
|
3268
|
+
);
|
|
3269
|
+
return {
|
|
3270
|
+
byDeployId,
|
|
3271
|
+
unmatched: requested.filter((id) => !deployList.some((deployId) => byDeployId[deployId].ids.includes(id))),
|
|
3272
|
+
};
|
|
3273
|
+
};
|
|
3274
|
+
|
|
2259
3275
|
export {
|
|
2260
3276
|
Config,
|
|
2261
3277
|
loadConf,
|
|
2262
3278
|
loadConfInstances,
|
|
3279
|
+
normalizeInstanceTopology,
|
|
3280
|
+
dispatchBuildInstanceEnv,
|
|
3281
|
+
loadProjectInstanceEnvBuilder,
|
|
2263
3282
|
loadInstanceTopology,
|
|
2264
3283
|
readConfInstances,
|
|
2265
3284
|
selectConfInstances,
|
|
@@ -2282,9 +3301,8 @@ export {
|
|
|
2282
3301
|
buildKindPorts,
|
|
2283
3302
|
buildPortProxyRouter,
|
|
2284
3303
|
splitFileFactory,
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
writeEnv,
|
|
3304
|
+
generateSecurePassword,
|
|
3305
|
+
resolveReplicaCount,
|
|
2288
3306
|
pathPortAssignmentFactory,
|
|
2289
3307
|
deployRangePortFactory,
|
|
2290
3308
|
awaitDeployMonitor,
|
|
@@ -2304,9 +3322,39 @@ export {
|
|
|
2304
3322
|
getConfFilePath,
|
|
2305
3323
|
readConfJson,
|
|
2306
3324
|
DEFAULT_DEPLOY_ID,
|
|
2307
|
-
|
|
2308
|
-
|
|
3325
|
+
clusterContextFactory,
|
|
3326
|
+
clusterTypeFactory,
|
|
3327
|
+
exposeTcpPortsFactory,
|
|
3328
|
+
exposePathPartsFactory,
|
|
3329
|
+
exposePartialMatchesFactory,
|
|
3330
|
+
exposePortListFactory,
|
|
3331
|
+
exposePortPlanFactory,
|
|
3332
|
+
deployHostsFactory,
|
|
3333
|
+
clusterInstancesFactory,
|
|
2309
3334
|
etcHostFactory,
|
|
3335
|
+
gatewayApiEnabledFactory,
|
|
3336
|
+
instanceHttpRouteRulesFactory,
|
|
3337
|
+
instanceInterceptStatusesFactory,
|
|
3338
|
+
instancePortFactory,
|
|
3339
|
+
instanceProjectPathFactory,
|
|
3340
|
+
instanceProxyRoutesFactory,
|
|
3341
|
+
instanceStatusPageDeployIdFactory,
|
|
3342
|
+
instanceStatusPageEntriesFactory,
|
|
3343
|
+
deployTrafficEntriesFactory,
|
|
3344
|
+
trafficProbePathsFactory,
|
|
3345
|
+
hostIngressFactsFactory,
|
|
3346
|
+
curlStatusChainFactory,
|
|
3347
|
+
hostRenderInstancesFactory,
|
|
3348
|
+
instanceTrafficPlanFactory,
|
|
3349
|
+
isTrafficServingFactory,
|
|
3350
|
+
nextTrafficFactory,
|
|
3351
|
+
schedulableNodeFactory,
|
|
3352
|
+
stopPlanFactory,
|
|
3353
|
+
trafficFromRoutingInfoFactory,
|
|
3354
|
+
trafficTableRowsFactory,
|
|
3355
|
+
resolveEnvScoped,
|
|
3356
|
+
sortInstancesByPath,
|
|
3357
|
+
waitForPort,
|
|
2310
3358
|
resolveDeployList,
|
|
2311
3359
|
syncPrivateConf,
|
|
2312
3360
|
syncDeployIdSources,
|