underpost 3.2.80 → 3.2.90

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