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.
Files changed (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -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 getNpmRootPath
1383
- * @description Gets the npm root path.
1384
- * @returns {string} - The npm root path.
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 getNpmRootPath = () =>
1388
- shellExec(`npm root -g`, {
1389
- stdout: true,
1390
- disableLog: true,
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 writeEnv
1404
- * @description Writes the environment variables.
1405
- * @param {string} envPath - The environment path.
1406
- * @param {object} envObj - The environment object.
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 writeEnv = (envPath, envObj) =>
1410
- fs.writeFileSync(
1411
- envPath,
1412
- Object.keys(envObj)
1413
- .map((key) => `${key}=${envObj[key]}`)
1414
- .join(`\n`),
1415
- 'utf8',
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 `{ default, variants }` describing the deploy's instance
1811
- * variants, or `null` when the deploy is single-instance. The topology is
1812
- * declared per instance entry (`entry.multiInstance.default` / `.variants`); all
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 {?{default: string, variants: Array<object>}} The topology, or `null`.
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 { default: mi.default || mi.variants[0].code, variants: mi.variants };
1781
+ return normalizeInstanceTopology(mi, `${deployId}/${entry.id}`);
1824
1782
  }
1825
1783
  return null;
1826
1784
  };
1827
1785
 
1828
1786
  /**
1829
- * @method resolveInstanceEnvValue
1830
- * @description Resolves one declared env value. A plain string is used verbatim;
1831
- * an object is treated as env-scoped (`{ development, production }`), matching the
1832
- * convention already used by `lifecycle` / `readinessProbe` / `livenessProbe`.
1833
- * Placeholders are substituted from the variant tokens.
1834
- * @param {string|object} value - Declared value.
1835
- * @param {string} env - `development` | `production`.
1836
- * @param {Object<string,string>} tokens - Placeholder name → replacement.
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 resolveInstanceEnvValue = (value, env, tokens) => {
1841
- const scoped = value && typeof value === 'object' && !Array.isArray(value) ? value[env] : value;
1842
- if (scoped === undefined || scoped === null) return null;
1843
- return `${scoped}`.replace(/\{\{(\w+)\}\}/g, (match, token) => (token in tokens ? tokens[token] : match));
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 whose `slug` is empty keeps the template id verbatim,
1855
- * so pre-existing deployments, PVCs and env directories survive the move to
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: the variant set (`default` + `variants`),
1859
- * which env keys an instance needs (`env`), and prefix-stripping (`stripPathPrefix`)
1860
- * are all declared per entry under `entry.multiInstance`. Env values may use the
1861
- * `{{code}}`, `{{slug}}`, `{{path}}`, `{{id}}` and `{{default}}` placeholders and
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 three extra fields consumed by the deploy runners:
1865
- * `instanceCode` (the variant this instance serves), `templateId` (so targeting
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
- const variants = spec?.variants;
1881
- if (!Array.isArray(variants) || 0 === variants.length) {
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.slug ? `${entry.id}-${variant.slug}` : entry.id;
1887
- const instance = variant.slug ? deepReplaceToken(entry, entry.id, id) : JSON.parse(JSON.stringify(entry));
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
- * @returns {object} - Object containing the rendered hosts file.
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
- if (options && options.append && fs.existsSync(`/etc/hosts`)) {
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
- `/etc/hosts`,
1963
- fs.readFileSync(`/etc/hosts`, 'utf8') +
2924
+ hostsPath,
2925
+ fs.readFileSync(hostsPath, 'utf8') +
1964
2926
  `
1965
2927
  ${renderHosts}`,
1966
2928
  'utf8',
1967
2929
  );
1968
- } else fs.writeFileSync(`/etc/hosts`, renderHosts, 'utf8');
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`; any other value is parsed as a comma separated list.
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' ? fs.readFileSync('./engine-private/deploy/dd.router', 'utf8') : deployId)
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
- getNpmRootPath,
2286
- getUnderpostRootPath,
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
- loadCronDeployEnv,
2308
- cronDeployIdResolve,
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,