underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
3,365 lines • 143 kB
JavaScript
/**
* Provides utilities for building, loading, and managing server configurations,
* deployment contexts, and service configurations (API, Client, WS).
* @module src/server/conf.js
* @namespace ServerConfBuilder
*/
import fs from 'fs-extra';
import dotenv from 'dotenv';
import {
capFirst,
getCapVariableName,
getDirname,
newInstance,
orderAbc,
orderArrayFromAttrInt,
range,
timer,
} from '../client/components/core/CommonJs.js';
import * as dir from 'path';
import net from 'net';
import crypto from 'crypto';
import colors from 'colors';
import { loggerFactory } from './logger.js';
import { writeEnv } from './environment.js';
import { shellExec } from './process.js';
import { UNDERPOST_GATEWAY, statusPageAssetPathFactory } from './underpost-gateway.js';
import { DefaultConf } from '../../conf.js';
import splitFile from 'split-file';
import Underpost from '../index.js';
colors.enable();
const logger = loggerFactory(import.meta);
/**
* Prefix used in JSON configuration files to denote an environment variable reference.
* Any string value in a config object that starts with this prefix will be resolved
* to the corresponding `process.env` value at runtime.
*
* @constant {string}
* @memberof ServerConfBuilder
* @example
* // In conf.server.json:
* { "db": { "password": "env:MARIADB_PASSWORD" } }
*/
const ENV_REF_PREFIX = 'env:';
/**
* Default deploy ID used when no deploy ID is specified.
* @constant {string}
* @memberof ServerConfBuilder
*/
const DEFAULT_DEPLOY_ID = 'dd-default';
/**
* Resolves a standardized context key from host/path descriptors.
* The key is used across DB, WS, mailer, and cache registries.
*
* @method resolveHostKeyContext
* @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or prebuilt key.
* @returns {string} Host key context string.
* @memberof ServerConfBuilder
*/
const resolveHostKeyContext = (context = { host: '', path: '' }) => {
if (typeof context === 'string') return context;
return `${context.host || ''}${context.path || ''}`;
};
/**
* Recursively walks a configuration object and replaces every string value that
* starts with {@link ENV_REF_PREFIX} (`"env:"`) with the corresponding
* `process.env[VAR_NAME]` value.
*
* Non-string values and strings that do not start with the prefix are left untouched.
*
* Supports three reference formats:
* - `"env:VAR_NAME"` — resolves to `process.env.VAR_NAME`, returns `''` if unset.
* - `"env:VAR_NAME:default_value"` — resolves to `process.env.VAR_NAME`, falls back to `default_value` if unset.
* - Type-coerced defaults:
* - `"env:VAR_NAME:int:465"` — resolved value is parsed as integer (`parseInt`), falls back to `465`.
* - `"env:VAR_NAME:bool:true"` — resolved value is coerced to boolean (`value !== 'false'`), falls back to `true`.
*
* @method resolveConfSecrets
* @param {any} obj - The configuration object (or value) to resolve.
* @returns {any} A **new** object with all `env:` references replaced by their runtime values.
* @memberof ServerConfBuilder
*
* @example
* // Given process.env.MARIADB_PASSWORD = 'supersecret'
* resolveConfSecrets({ db: { password: 'env:MARIADB_PASSWORD' } });
* // => { db: { password: 'supersecret' } }
*
* @example
* // With default value fallback (env var not set)
* resolveConfSecrets({ db: { provider: 'env:DB_PROVIDER:mongoose' } });
* // => { db: { provider: 'mongoose' } }
*
* @example
* // With int type coercion
* resolveConfSecrets({ port: 'env:SMTP_PORT:int:465' });
* // => { port: 465 }
*
* @example
* // With bool type coercion
* resolveConfSecrets({ secure: 'env:SMTP_SECURE:bool:true' });
* // => { secure: true }
*/
const resolveConfSecrets = (obj) => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
if (obj.startsWith(ENV_REF_PREFIX)) {
const ref = obj.slice(ENV_REF_PREFIX.length);
// Support env:VAR_NAME:default_value syntax (first colon separates key from default)
const colonIdx = ref.indexOf(':');
const envKey = colonIdx !== -1 ? ref.slice(0, colonIdx) : ref;
const defaultValue = colonIdx !== -1 ? ref.slice(colonIdx + 1) : undefined;
const envValue = process.env[envKey];
let resolved;
if (envValue !== undefined) {
resolved = envValue;
} else if (defaultValue !== undefined) {
resolved = defaultValue;
} else {
logger.warn(`resolveConfSecrets: environment variable "${envKey}" is not set (referenced as "${obj}")`);
return '';
}
// Type coercion via prefix in default value: int:N or bool:B
// Also apply coercion when an env value is present and a typed default is declared
if (defaultValue !== undefined) {
if (defaultValue.startsWith('int:')) {
return parseInt(resolved, 10) || parseInt(defaultValue.slice(4), 10) || 0;
}
if (defaultValue.startsWith('bool:')) {
const boolDefault = defaultValue.slice(5);
if (envValue !== undefined) return envValue !== 'false';
return boolDefault !== 'false';
}
}
return resolved;
}
return obj;
}
if (Array.isArray(obj)) return obj.map((item) => resolveConfSecrets(item));
if (typeof obj === 'object') {
const resolved = {};
for (const [key, value] of Object.entries(obj)) {
resolved[key] = resolveConfSecrets(value);
}
return resolved;
}
return obj;
};
/**
* Returns the private configuration folder for a given deploy ID.
* Checks for a replica folder first, then falls back to the standard conf folder.
*
* @method getConfFolder
* @param {string} deployId - The deploy ID.
* @returns {string} The path to the private configuration folder.
* @memberof ServerConfBuilder
*
* @example
* getConfFolder('dd-myapp');
* // => './engine-private/conf/dd-myapp' (or './engine-private/replica/dd-myapp' if it exists)
*/
const getConfFolder = (deployId) => {
return fs.existsSync(`./engine-private/replica/${deployId}`)
? `./engine-private/replica/${deployId}`
: `./engine-private/conf/${deployId}`;
};
/**
* Resolves the full path to a specific configuration JSON file for a deploy ID.
* For `server` configs in development mode with a subConf, it will prefer the
* dev-specific variant if it exists.
*
* @method getConfFilePath
* @param {string} deployId - The deploy ID.
* @param {string} confType - The configuration type (e.g. 'server', 'client', 'cron', 'ssr').
* @param {string} [subConf=''] - Optional sub-configuration identifier (used for dev server variants).
* @returns {string} The resolved path to the configuration JSON file.
* @memberof ServerConfBuilder
*
* @example
* getConfFilePath('dd-myapp', 'server');
* // => './engine-private/conf/dd-myapp/conf.server.json'
*
* @example
* // In development with subConf 'local':
* getConfFilePath('dd-myapp', 'server', 'local');
* // => './engine-private/conf/dd-myapp/conf.server.dev.local.json' (if it exists)
*/
const getConfFilePath = (deployId, confType, subConf = '') => {
const folder = getConfFolder(deployId);
// When no explicit subConf is given, fall back to the env var set by loadConf()
const effectiveSubConf = subConf || process.env.DEPLOY_SUB_CONF || '';
if (confType === 'server' && effectiveSubConf) {
const devConfPath = `${folder}/conf.${confType}.dev.${effectiveSubConf}.json`;
if (fs.existsSync(devConfPath)) return devConfPath;
}
return `${folder}/conf.${confType}.json`;
};
/**
* Reads and parses a configuration JSON file for a given deploy ID and config type.
* Optionally resolves `env:` secret references and/or applies replica expansion.
*
* @method readConfJson
* @param {string} deployId - The deploy ID.
* @param {string} confType - The configuration type (e.g. 'server', 'client', 'cron', 'ssr').
* @param {object} [options={}] - Options.
* @param {string} [options.subConf=''] - Sub-configuration identifier for dev variants.
* @param {boolean} [options.resolve=false] - Whether to resolve `env:` references.
* @param {boolean} [options.loadReplicas=false] - Whether to expand replica entries (server configs).
* @returns {object} The parsed (and optionally resolved) configuration object.
* @memberof ServerConfBuilder
*/
const readConfJson = (deployId, confType, options = {}) => {
const filePath = getConfFilePath(deployId, confType, options.subConf || '');
if (!fs.existsSync(filePath)) {
throw new Error(`readConfJson: configuration file not found: ${filePath}`);
}
let parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (options.loadReplicas && confType === 'server') parsed = loadReplicas(deployId, parsed);
if (options.resolve) parsed = resolveConfSecrets(parsed);
return parsed;
};
/**
* @class Config
* @description Manages the configuration of the server.
* This class provides a set of static methods to automate various
* infrastructure operations, including NFS management, control server setup,
* and system provisioning for different architectures.
* @memberof ServerConfBuilder
*/
const Config = {
/**
* @method default
* @description The default configuration of the server.
* @memberof ServerConfBuilder
*/
default: DefaultConf,
/**
* @method build
* @description Builds the configuration of the server.
* @param {string} [deployContext='dd-default'] - The deploy context.
* @param {string} [deployList=''] - The deploy list.
* @param {string} [subConf=''] - The sub configuration.
* @memberof ServerConfBuilder
*/
build: async function (deployContext = DEFAULT_DEPLOY_ID, deployList, subConf) {
if (process.argv[2] && typeof process.argv[2] === 'string' && process.argv[2].startsWith('dd-'))
deployContext = process.argv[2];
else if (deployContext !== 'proxy' && process.env.DEPLOY_ID && process.env.DEPLOY_ID.startsWith('dd-'))
deployContext = process.env.DEPLOY_ID;
if (!subConf && process.argv[3] && typeof process.argv[3] === 'string') subConf = process.argv[3];
Underpost.env.set('await-deploy', new Date().toISOString());
if (deployContext.startsWith('dd-')) loadConf(deployContext, subConf);
if (deployContext === 'proxy') await Config.buildProxy(deployList, subConf);
},
/**
* @method deployIdFactory
* @description Creates a new deploy ID.
* @param {string} [deployId='dd-default']
* @param {object} [options={ subConf: '', cluster: false }] - The options.
* @memberof ServerConfBuilder
*/
deployIdFactory: function (deployId = DEFAULT_DEPLOY_ID, options = { subConf: '', cluster: false }) {
if (!deployId.startsWith('dd-')) deployId = `dd-${deployId}`;
logger.info('Build deployId', deployId);
const folder = `./engine-private/conf/${deployId}`;
const repoName = `engine-${deployId.split('dd-')[1]}`;
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
const sharedEnvTemplate = fs.existsSync('./.env.example')
? fs.readFileSync('./.env.example', 'utf8')
: fs.existsSync('./.env.production')
? fs.readFileSync('./.env.production', 'utf8')
: '';
const envTemplates = {
production: fs.existsSync('./.env.production') ? fs.readFileSync('./.env.production', 'utf8') : sharedEnvTemplate,
development: fs.existsSync('./.env.development')
? fs.readFileSync('./.env.development', 'utf8')
: sharedEnvTemplate
? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=development').replace('PORT=3000', 'PORT=4000')
: '',
test: fs.existsSync('./.env.test')
? fs.readFileSync('./.env.test', 'utf8')
: sharedEnvTemplate
? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=test').replace('PORT=3000', 'PORT=5000')
: '',
};
for (const [envName, envTemplate] of Object.entries(envTemplates)) {
if (!envTemplate) continue;
fs.writeFileSync(`${folder}/.env.${envName}`, envTemplate.replaceAll('dd-default', deployId), 'utf8');
}
fs.writeFileSync(
`${folder}/package.json`,
fs.readFileSync('./package.json', 'utf8').replaceAll('dd-default', deployId),
'utf8',
);
// Write default conf JSON files if they don't exist
for (const confType of Object.keys(this.default)) {
const confPath = `${folder}/conf.${confType}.json`;
if (!fs.existsSync(confPath)) fs.writeFileSync(confPath, JSON.stringify(this.default[confType], null, 4), 'utf8');
}
if (options.subConf) {
logger.info('Creating sub conf', {
deployId: deployId,
subConf: options.subConf,
});
fs.copySync(
`./engine-private/conf/${deployId}/conf.server.json`,
`./engine-private/conf/${deployId}/conf.server.dev.${options.subConf}.json`,
);
}
if (options.cluster === true) {
fs.writeFileSync(
`./.github/workflows/${repoName}.cd.yml`,
fs.readFileSync(`./.github/workflows/engine-test.cd.yml`, 'utf8').replaceAll('test', deployId.split('dd-')[1]),
'utf8',
);
fs.writeFileSync(
`./.github/workflows/${repoName}.ci.yml`,
fs.readFileSync(`./.github/workflows/engine-test.ci.yml`, 'utf8').replaceAll('test', deployId.split('dd-')[1]),
'utf8',
);
shellExec(`node bin new --default-conf --deploy-id ${deployId}`);
if (!fs.existsSync(`./engine-private/deploy/dd.router`))
fs.writeFileSync(`./engine-private/deploy/dd.router`, deployId, 'utf8');
else
fs.writeFileSync(
`./engine-private/deploy/dd.router`,
fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').trim() + `,${deployId}`,
'utf8',
);
}
return { deployIdFolder: folder, deployId };
},
/**
* @method buildProxyByDeployId
* @description Builds the proxy by deploy ID.
* @param {string} [deployId='dd-default'] - The deploy ID.
* @param {string} [subConf=''] - The sub configuration.
* @memberof ServerConfBuilder
*/
buildProxyByDeployId: function (deployId = 'dd-default', subConf = '') {
let confPath = fs.existsSync(`./engine-private/replica/${deployId}/conf.server.json`)
? `./engine-private/replica/${deployId}/conf.server.json`
: `./engine-private/conf/${deployId}/conf.server.json`;
if (
process.env.NODE_ENV === 'development' &&
subConf &&
fs.existsSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`)
)
confPath = `./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`;
const serverConf = loadConfServerJson(confPath);
for (const host of Object.keys(loadReplicas(deployId, serverConf)))
this.default.server[host] = {
...this.default.server[host],
...serverConf[host],
};
},
/**
* @method buildProxy
* @description Builds the proxy.
* @param {string} [deployList='dd-default'] - The deploy list.
* @param {string} [subConf=''] - The sub configuration.
* @memberof ServerConfBuilder
*/
buildProxy: async function (deployList = 'dd-default', subConf = '') {
if (!deployList) deployList = process.argv[3];
if (!subConf) subConf = process.argv[4];
this.default.server = {};
for (const deployId of deployList.split(',')) {
this.buildProxyByDeployId(deployId, subConf);
if (fs.existsSync(`./engine-private/replica`)) {
const singleReplicas = await fs.readdir(`./engine-private/replica`);
for (let replica of singleReplicas) {
if (replica.startsWith(deployId)) this.buildProxyByDeployId(replica, subConf);
}
}
}
},
};
/**
* @method loadConf
* @description Loads the configuration of the server.
* @param {string} [deployId='dd-default'] - The deploy ID.
* @param {string} [subConf=''] - The sub configuration.
* @memberof ServerConfBuilder
*/
const loadConf = (deployId = DEFAULT_DEPLOY_ID, subConf) => {
if (deployId === 'current') {
console.log(process.env.DEPLOY_ID);
return;
}
if (deployId === 'clean') {
const path = '.';
fs.removeSync(`${path}/.env`);
fs.removeSync(`${path}/.env.production`);
fs.removeSync(`${path}/.env.development`);
fs.removeSync(`${path}/.env.test`);
return;
}
const folder = getConfFolder(deployId);
if (!fs.existsSync(folder)) Config.deployIdFactory(deployId);
if (subConf) process.env.DEPLOY_SUB_CONF = subConf;
for (const typeConf of Object.keys(Config.default)) {
let srcConf = fs.readFileSync(`${folder}/conf.${typeConf}.json`, 'utf8');
if (process.env.NODE_ENV === 'development' && typeConf === 'server' && subConf) {
const devConfPath = `${folder}/conf.${typeConf}.dev${subConf ? `.${subConf}` : ''}.json`;
if (fs.existsSync(devConfPath)) srcConf = fs.readFileSync(devConfPath, 'utf8');
}
let parsed = JSON.parse(srcConf);
if (typeConf === 'server') parsed = loadReplicas(deployId, parsed);
Config.default[typeConf] = parsed;
}
fs.writeFileSync(`./.env.production`, fs.readFileSync(`${folder}/.env.production`, 'utf8'), 'utf8');
fs.writeFileSync(`./.env.development`, fs.readFileSync(`${folder}/.env.development`, 'utf8'), 'utf8');
fs.writeFileSync(`./.env.test`, fs.readFileSync(`${folder}/.env.test`, 'utf8'), 'utf8');
const NODE_ENV = process.env.NODE_ENV || 'development';
if (NODE_ENV) {
const subPathEnv = fs.existsSync(`${folder}/.env.${NODE_ENV}.${subConf}`)
? `${folder}/.env.${NODE_ENV}.${subConf}`
: `${folder}/.env.${NODE_ENV}`;
fs.writeFileSync(`./.env`, fs.readFileSync(subPathEnv, 'utf8'), 'utf8');
const env = dotenv.parse(fs.readFileSync(subPathEnv, 'utf8'));
process.env = {
...process.env,
...env,
};
}
const originPackageJson = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
const packageJson = JSON.parse(fs.readFileSync(`${folder}/package.json`, 'utf8'));
originPackageJson.scripts.start = packageJson.scripts.start;
packageJson.scripts = originPackageJson.scripts;
fs.writeFileSync(`./package.json`, JSON.stringify(packageJson, null, 4), 'utf8');
return { folder, deployId };
};
/**
* @method loadReplicas
* @description Loads the replicas of the server.
* @param {object} confServer - The server configuration.
* @memberof ServerConfBuilder
*/
const loadReplicas = (deployId, confServer) => {
const confServerOrigin = newInstance(confServer);
for (const host of Object.keys(confServer)) {
for (const path of Object.keys(confServer[host])) {
const { replicas, singleReplica } = confServer[host][path];
if (replicas) {
if (!singleReplica)
for (const replicaPath of replicas) {
{
confServer[host][replicaPath] = newInstance(confServer[host][path]);
delete confServer[host][replicaPath].replicas;
}
}
else {
const orderReplica = orderAbc(confServerOrigin[host][path].replicas);
confServerOrigin[host][path].replicas = orderReplica;
confServer[host][path].replicas = orderReplica;
}
}
}
}
const serverPath = `./engine-private/conf/${deployId}/conf.server${process.env.NODE_ENV === 'production' ? '' : '.dev'}.json`;
if (fs.existsSync(serverPath)) fs.writeFileSync(serverPath, JSON.stringify(confServerOrigin, null, 4), 'utf8');
return confServer;
};
/**
* @method cloneConf
* @description Clones the configuration of the server.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const cloneConf = async (
{ toOptions, fromOptions },
fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;
const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
const confToFolder = `./engine-private/conf/${toOptions.deployId}`;
const toClientVariableName = getCapVariableName(toOptions.clientId);
const fromClientVariableName = getCapVariableName(fromOptions.clientId);
const formattedSrc = (dataConf) =>
JSON.stringify(dataConf, null, 4)
.replaceAll(fromClientVariableName, toClientVariableName)
.replaceAll(fromOptions.clientId, toOptions.clientId);
const isMergeConf = fs.existsSync(confToFolder);
if (!isMergeConf) fs.mkdirSync(confToFolder, { recursive: true });
fs.writeFileSync(
`${confToFolder}/.env.production`,
fs.readFileSync(`${confFromFolder}/.env.production`, 'utf8'),
'utf8',
);
fs.writeFileSync(
`${confToFolder}/.env.development`,
fs.readFileSync(`${confFromFolder}/.env.development`, 'utf8'),
'utf8',
);
fs.writeFileSync(`${confToFolder}/.env.test`, fs.readFileSync(`${confFromFolder}/.env.test`, 'utf8'), 'utf8');
for (const confTypeId of ['server', 'client', 'cron', 'ssr']) {
const confFromData = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.${confTypeId}.json`, 'utf8'));
fs.writeFileSync(`${confToFolder}/conf.${confTypeId}.json`, formattedSrc(confFromData), 'utf8');
}
const packageData = JSON.parse(fs.readFileSync(`${confFromFolder}/package.json`, 'utf8'));
packageData.scripts.start = packageData.scripts.start.replaceAll(fromOptions.deployId, toOptions.deployId);
fs.writeFileSync(`${confToFolder}/package.json`, JSON.stringify(packageData, null, 4), 'utf8');
};
/**
* @method addClientConf
* @description Adds the client configuration to the server.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const addClientConf = async (
{ toOptions, fromOptions },
fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;
const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
const confToFolder = `./engine-private/conf/${toOptions.deployId}`;
const toClientConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.client.json`, 'utf8'));
const fromClientConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.client.json`, 'utf8'));
const toClientVariableName = getCapVariableName(toOptions.clientId);
const fromClientVariableName = getCapVariableName(fromOptions.clientId);
const { host, path } = toOptions;
toClientConf[fromOptions.clientId] = fromClientConf[fromOptions.clientId];
fs.writeFileSync(`${confToFolder}/conf.client.json`, JSON.stringify(toClientConf, null, 4), 'utf8');
const toServerConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
const fromServerConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
toServerConf[host][path].client = fromOptions.clientId;
toServerConf[host][path].runtime = 'nodejs';
toServerConf[host][path].apis = fromClientConf[fromOptions.clientId].services;
fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(toServerConf, null, 4), 'utf8');
const fromSsrConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.ssr.json`, 'utf8'));
const toSsrConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.ssr.json`, 'utf8'));
toSsrConf[fromClientVariableName] = fromSsrConf[fromClientVariableName];
fs.writeFileSync(`${confToFolder}/conf.ssr.json`, JSON.stringify(toSsrConf, null, 4), 'utf8');
};
/**
* @method buildClientSrc
* @description Builds the client source code.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const buildClientSrc = async (
{ toOptions, fromOptions },
fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;
const confFromFolder = `./src/client/components/${fromOptions.clientId}`;
const confToFolder = `./src/client/components/${toOptions.clientId}`;
const toClientVariableName = getCapVariableName(toOptions.clientId);
const fromClientVariableName = getCapVariableName(fromOptions.clientId);
const formattedSrc = (src) =>
src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.clientId, toOptions.clientId);
const isMergeConf = fs.existsSync(confToFolder);
if (!isMergeConf) fs.mkdirSync(confToFolder, { recursive: true });
const files = await fs.readdir(confFromFolder, { recursive: true });
for (const relativePath of files) {
const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);
fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
}
fs.writeFileSync(
`./src/client/ssr/head/${toClientVariableName}Scripts.js`,
formattedSrc(fs.readFileSync(`./src/client/ssr/head/${fromClientVariableName}Scripts.js`, 'utf8')),
'utf8',
);
fs.writeFileSync(
`./src/client/${toClientVariableName}.index.js`,
formattedSrc(fs.readFileSync(`./src/client/${fromClientVariableName}.index.js`, 'utf8')),
'utf8',
);
fs.copySync(`./src/client/public/${fromOptions.clientId}`, `./src/client/public/${toOptions.clientId}`);
};
/**
* @method buildApiSrc
* @description Builds the API source code.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ apiId: 'default', deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const buildApiSrc = async (
{ toOptions, fromOptions },
fromDefaultOptions = { apiId: 'default', deployId: 'dd-default', clientId: 'default' },
) => {
if (!fromOptions.apiId) fromOptions.apiId = fromDefaultOptions.apiId;
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;
const toClientVariableName = getCapVariableName(toOptions.apiId);
const fromClientVariableName = getCapVariableName(fromOptions.apiId);
const formattedSrc = (src) =>
src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.apiId, toOptions.apiId);
const apiToFolder = `./src/api/${toOptions.apiId}`;
const apiFromFolder = `./src/api/${fromOptions.apiId}`;
const isMergeConf = fs.existsSync(apiToFolder);
if (!isMergeConf) fs.mkdirSync(apiToFolder, { recursive: true });
for (const srcApiType of ['model', 'controller', 'service', 'router']) {
fs.writeFileSync(
`${apiToFolder}/${toOptions.apiId}.${srcApiType}.js`,
formattedSrc(fs.readFileSync(`${apiFromFolder}/${fromOptions.apiId}.${srcApiType}.js`, 'utf8')),
'utf8',
);
}
fs.mkdirSync(`./src/client/services/${toOptions.apiId}`, { recursive: true });
if (fs.existsSync(`./src/client/services/${fromOptions.apiId}/${fromOptions.apiId}.service.js`))
fs.writeFileSync(
`./src/client/services/${toOptions.apiId}/${toOptions.apiId}.service.js`,
formattedSrc(
fs.readFileSync(`./src/client/services/${fromOptions.apiId}/${fromOptions.apiId}.service.js`, 'utf8'),
),
'utf8',
);
};
/**
* @method addApiConf
* @description Adds the API configuration to the server.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ apiId: 'default', deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const addApiConf = async (
{ toOptions, fromOptions },
fromDefaultOptions = { apiId: 'default', deployId: 'dd-default', clientId: 'default' },
) => {
if (!fromOptions.apiId) fromOptions.apiId = fromDefaultOptions.apiId;
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;
const toClientVariableName = getCapVariableName(toOptions.apiId);
const fromClientVariableName = getCapVariableName(fromOptions.apiId);
const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
const confToFolder = `./engine-private/conf/${toOptions.deployId}`;
const confServer = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
for (const host of Object.keys(confServer))
for (const path of Object.keys(confServer[host]))
if (confServer[host][path].apis) confServer[host][path].apis.push(toOptions.apiId);
fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(confServer, null, 4), 'utf8');
const confClient = JSON.parse(fs.readFileSync(`${confToFolder}/conf.client.json`, 'utf8'));
confClient[toOptions.clientId].services.push(toOptions.apiId);
fs.writeFileSync(`${confToFolder}/conf.client.json`, JSON.stringify(confClient, null, 4), 'utf8');
};
/**
* @method addWsConf
* @description Adds the WebSocket configuration to the server.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const addWsConf = async (
{ toOptions, fromOptions },
fromDefaultOptions = { wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' },
) => {
if (!fromOptions.wsId) fromOptions.wsId = fromDefaultOptions.wsId;
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.host) fromOptions.host = fromDefaultOptions.host;
if (!fromOptions.paths) fromOptions.paths = fromDefaultOptions.paths;
const toClientVariableName = getCapVariableName(toOptions.apiId);
const fromClientVariableName = getCapVariableName(fromOptions.apiId);
const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
const confToFolder = `./engine-private/conf/${toOptions.deployId}`;
const paths = toOptions.paths.split(',');
const confServer = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
for (const host of Object.keys(confServer))
for (const path of Object.keys(confServer[host]))
if (host === toOptions.host && paths.includes(path) && confServer[host][path])
confServer[host][path].ws = toOptions.wsId;
fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(confServer, null, 4), 'utf8');
};
/**
* @method buildWsSrc
* @description Builds the WebSocket source code.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @param {object} [fromDefaultOptions={ wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' }] - The default options for the source configuration.
* @memberof ServerConfBuilder
*/
const buildWsSrc = async (
{ toOptions, fromOptions },
fromDefaultOptions = { wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' },
) => {
if (!fromOptions.wsId) fromOptions.wsId = fromDefaultOptions.wsId;
if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
if (!fromOptions.host) fromOptions.host = fromDefaultOptions.host;
if (!fromOptions.paths) fromOptions.paths = fromDefaultOptions.paths;
const toClientVariableName = getCapVariableName(toOptions.wsId);
const fromClientVariableName = getCapVariableName(fromOptions.wsId);
const confFromFolder = `./src/ws/${fromOptions.wsId}`;
const confToFolder = `./src/ws/${toOptions.wsId}`;
const paths = toOptions.paths.split(',');
const formattedSrc = (src) =>
src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.wsId, toOptions.wsId);
const files = await fs.readdir(confFromFolder, { recursive: true });
for (const relativePath of files) {
const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);
if (fs.lstatSync(fromFilePath).isDirectory() && !fs.existsSync(formattedSrc(toFilePath)))
fs.mkdirSync(formattedSrc(toFilePath), { recursive: true });
if (fs.lstatSync(fromFilePath).isFile() && !fs.existsSync(formattedSrc(toFilePath))) {
fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
}
}
};
/**
* @method cloneSrcComponents
* @description Clones the source components.
* @param {object} toOptions - The options for the target configuration.
* @param {object} fromOptions - The options for the source configuration.
* @memberof ServerConfBuilder
*/
const cloneSrcComponents = async ({ toOptions, fromOptions }) => {
const toClientVariableName = getCapVariableName(toOptions.componentsFolder);
const fromClientVariableName = getCapVariableName(fromOptions.componentsFolder);
const formattedSrc = (src) =>
src
.replaceAll(fromClientVariableName, toClientVariableName)
.replaceAll(fromOptions.componentsFolder, toOptions.componentsFolder);
const confFromFolder = `./src/client/components/${fromOptions.componentsFolder}`;
const confToFolder = `./src/client/components/${toOptions.componentsFolder}`;
fs.mkdirSync(confToFolder, { recursive: true });
const files = await fs.readdir(confFromFolder);
for (const relativePath of files) {
const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);
fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
}
};
/**
* @method buildProxyRouter
* @description Builds the proxy router.
* @memberof ServerConfBuilder
*/
const buildProxyRouter = () => {
const confServer = newInstance(Config.default.server);
let currentPort = parseInt(process.env.PORT) + 1;
const proxyRouter = {};
for (const host of Object.keys(confServer)) {
for (const path of Object.keys(confServer[host])) {
if (confServer[host][path].singleReplica) continue;
if (isDevProxyContext()) confServer[host][path].proxy = [isTlsDevProxy() ? 443 : 80];
confServer[host][path].port = newInstance(currentPort);
for (const port of confServer[host][path].proxy) {
if (!(port in proxyRouter)) proxyRouter[port] = {};
proxyRouter[port][`${host}${path}`] = {
// target: `http://${host}:${confServer[host][path].port}${path}`,
target: `http://localhost:${confServer[host][path].port}`,
// target: `http://127.0.0.1:${confServer[host][path].port}`,
proxy: confServer[host][path].proxy,
redirect: confServer[host][path].redirect,
host,
path,
};
}
currentPort++;
if (confServer[host][path].peer) {
const peerPath = path === '/' ? `/peer` : `${path}/peer`;
confServer[host][peerPath] = newInstance(confServer[host][path]);
confServer[host][peerPath].port = newInstance(currentPort);
for (const port of confServer[host][path].proxy) {
if (!(port in proxyRouter)) proxyRouter[port] = {};
proxyRouter[port][`${host}${peerPath}`] = {
// target: `http://${host}:${confServer[host][peerPath].port}${peerPath}`,
target: `http://localhost:${confServer[host][peerPath].port}`,
// target: `http://127.0.0.1:${confServer[host][peerPath].port}`,
proxy: confServer[host][peerPath].proxy,
host,
path: peerPath,
};
}
currentPort++;
}
}
}
return proxyRouter;
};
/**
* @method pathPortAssignmentFactory
* @description Creates the path port assignment.
* @param {string} deployId - The deploy ID.
* @param {object} router - The router.
* @param {object} confServer - The server configuration.
* @memberof ServerConfBuilder
*/
const pathPortAssignmentFactory = async (deployId, router, confServer) => {
const pathPortAssignmentData = {};
for (const host of Object.keys(confServer)) {
const pathPortAssignment = [];
for (const path of Object.keys(confServer[host])) {
const { peer } = confServer[host][path];
if (!router[`${host}${path === '/' ? '' : path}`]) continue;
const port = parseInt(router[`${host}${path === '/' ? '' : path}`].split(':')[2]);
// logger.info('', { host, port, path });
pathPortAssignment.push({
port,
path,
});
if (peer) {
// logger.info('', { host, port: port + 1, path: '/peer' });
pathPortAssignment.push({
port: port + 1,
path: `${path === '/' ? '' : path}/peer`,
});
}
}
pathPortAssignmentData[host] = pathPortAssignment;
}
if (fs.existsSync(`./engine-private/replica`)) {
const singleReplicas = await fs.readdir(`./engine-private/replica`);
for (let replica of singleReplicas) {
if (replica.startsWith(deployId)) {
const replicaServerConf = loadConfServerJson(`./engine-private/replica/${replica}/conf.server.json`);
for (const host of Object.keys(replicaServerConf)) {
const pathPortAssignment = [];
for (const path of Object.keys(replicaServerConf[host])) {
const { peer } = replicaServerConf[host][path];
if (!router[`${host}${path === '/' ? '' : path}`]) continue;
const port = parseInt(router[`${host}${path === '/' ? '' : path}`].split(':')[2]);
// logger.info('', { host, port, path });
pathPortAssignment.push({
port,
path,
});
if (peer) {
// logger.info('', { host, port: port + 1, path: '/peer' });
pathPortAssignment.push({
port: port + 1,
path: `${path === '/' ? '' : path}/peer`,
});
}
}
pathPortAssignmentData[host] = pathPortAssignmentData[host].concat(pathPortAssignment);
}
}
}
}
return pathPortAssignmentData;
};
/**
* @method deployRangePortFactory
* @description Creates the deploy range port factory.
* @param {object} router - The router.
* @returns {object} - The deploy range port factory.
* @memberof ServerConfBuilder
*/
const deployRangePortFactory = (router) => {
const ports = Object.values(router).map((p) => parseInt(p.split(':')[2]));
const fromPort = Math.min(...ports);
const toPort = Math.max(...ports);
return { ports, fromPort, toPort };
};
/**
* @method buildKindPorts
* @description Builds the kind ports.
* @param {number} from - The from port.
* @param {number} to - The to port.
* @returns {string} - The kind ports.
* @memberof ServerConfBuilder
*/
const buildKindPorts = (from, to) =>
range(parseInt(from), parseInt(to))
.map(
(port) => ` - name: 'tcp-${port}'
protocol: TCP
port: ${port}
targetPort: ${port}
- name: 'udp-${port}'
protocol: UDP
port: ${port}
targetPort: ${port}
`,
)
.join('\n');
/**
* @method buildPortProxyRouter
* @description Builds the port proxy router.
* @param {object} options - The options.
* @param {number} [options.port=4000] - The port.
* @param {object} options.proxyRouter - The proxy router.
* @param {object} [options.hosts] - The hosts.
* @param {boolean} [options.orderByPathLength=false] - Whether to order by path length.
* @param {boolean} [options.devProxyContext=false] - Whether to use dev proxy context.
* @returns {object} - The port proxy router.
* @memberof ServerConfBuilder
*/
const buildPortProxyRouter = (
options = { port: 4000, proxyRouter, hosts, orderByPathLength: false, devProxyContext: false },
) => {
let { port, proxyRouter, hosts, orderByPathLength } = options;
hosts = hosts || proxyRouter[port] || {};
const router = {};
// build router
Object.keys(hosts).map((hostKey) => {
let { host, path, target, proxy, peer } = hosts[hostKey];
if (!proxy.includes(port)) {
logger.warn('Proxy port not set on conf', { port, host, path, proxy, target });
if (process.env.NODE_ENV === 'production') {
logger.warn('Omitting host', { host, path, target });
return;
}
}
// ${process.env.NODE_ENV === 'development' && !isDevProxyContext() ? `:${port}` : ''}
const absoluteHost = [80, 443].includes(port)
? `${host}${path === '/' ? '' : path}`
: `${host}:${port}${path === '/' ? '' : path}`;
if (absoluteHost in router)
logger.warn('Overwrite: Absolute host already exists on router', { absoluteHost, target });
if (options.devProxyContext === true) {
const appDevPort = parseInt(target.split(':')[2]) - process.env.DEV_PROXY_PORT_OFFSET;
router[absoluteHost] = `http://localhost:${appDevPort}`;
} else router[absoluteHost] = target;
}); // order router
if (Object.keys(router).length === 0) return router;
const devApiConfPath = `./engine-private/conf/${process.argv[3]}/conf.server.dev.${process.argv[4]}-dev-api.json`;
if (options.devProxyContext === true && process.env.NODE_ENV === 'development' && fs.existsSync(devApiConfPath)) {
const confDevApiServer = JSON.parse(fs.readFileSync(devApiConfPath, 'utf8'));
let devApiHosts = [];
let origins = [];
for (const _host of Object.keys(confDevApiServer))
for (const _path of Object.keys(confDevApiServer[_host])) {
if (confDevApiServer[_host][_path].origins && confDevApiServer[_host][_path].origins.length) {
origins.push(...confDevApiServer[_host][_path].origins);
if (_path !== 'peer' && devApiHosts.length === 0)
devApiHosts.push(
`${_host}${[80, 443].includes(port) && isDevProxyContext() ? '' : `:${port}`}${_path == '/' ? '' : _path}`,
);
}
}
origins = Array.from(new Set(origins));
console.log({
origins,
devApiHosts,
});
for (const devApiHost of devApiHosts) {
if (devApiHost in router) {
const target = router[devApiHost];
delete router[devApiHost];
router[`${devApiHost}/${process.env.BASE_API}`] = target;
router[`${devApiHost}/socket.io`] = target;
for (const origin of origins) router[devApiHost] = origin;
}
}
}
if (orderByPathLength === true) {
const reOrderRouter = {};
for (const absoluteHostKey of orderArrayFromAttrInt(Object.keys(router), 'length'))
reOrderRouter[absoluteHostKey] = router[absoluteHostKey];
return reOrderRouter;
}
return router;
};
/**
* @method buildReplicaId
* @description Builds the replica ID.
* @param {object} options - The options.
* @param {string} options.deployId - The deploy ID.
* @param {string} options.replica - The replica.
* @returns {string} - The replica ID.
* @memberof ServerConfBuilder
*/
const buildReplicaId = ({ deployId, replica }) => `${deployId}-${replica.slice(1)}`;
/**
* @method getDataDeploy
* @description Gets the data deploy.
* @param {object} options - The options.
* @param {boolean} [options.buildSingleReplica=false] - The build single replica.
* @param {string} [options.deployId] - The deploy ID.
* @param {boolean} [options.disableSyncEnvPort=false] - The disable sync env port.
* @returns {object} - The data deploy.
* @memberof ServerConfBuilder
*/
const getDataDeploy = async (
options = {
buildSingleReplica: false,
disableSyncEnvPort: false,
},
) => {
let dataDeploy = fs
.readFileSync(`./engine-private/deploy/dd.router`, 'utf8')
.split(',')
.map((deployId) => deployId.trim())
.filter((deployId) => deployId);
dataDeploy = dataDeploy.map((deployId) => {
return {
deployId,
};
});
if (options && options.buildSingleReplica && fs.existsSync(`./engine-private/replica`))
fs.removeSync(`./engine-private/replica`);
let buildDataDeploy = [];
for (const deployObj of dataDeploy) {
const isReplicaDeploy = fs.existsSync(`./engine-private/replica/${deployObj.deployId}`);
const serverConf = loadReplicas(
deployObj.deployId,
loadConfServerJson(`./engine-private/conf/${deployObj.deployId}/conf.server.json`),
);
let replicaDataDeploy = [];
for (const host of Object.keys(serverConf))
for (const path of Object.keys(serverConf[host])) {
if (!isReplicaDeploy && serverConf[host][path].replicas && serverConf[host][path].singleReplica) {
if (options && options.buildSingleReplica)
await Underpost.repo.client(deployObj.deployId, '', host, path, {
singleReplica: true,
});
replicaDataDeploy = replicaDataDeploy.concat(
serverConf[host][path].replicas.map((r) => {
return {
deployId: buildReplicaId({ deployId: deployObj.deployId, replica: r }),
replicaHost: host,
};
}),
);
}
}
buildDataDeploy.push(deployObj);
if (replicaDataDeploy.length > 0) buildDataDeploy = buildDataDeploy.concat(replicaDataDeploy);
}
if (!options.disableSyncEnvPort && options.buildSingleReplica)
await Underpost.repo.client(undefined, '', '', '', { syncEnvPort: true });
logger.info('Deployments configured', buildDataDeploy);
return buildDataDeploy;
};
/**
* @method validateTemplatePath
* @description Validates the template path.
* @param {string} absolutePath - The absolute path.
* @returns {boolean} - The validation result.
* @memberof ServerConfBuilder
*/
const validateTemplatePath = (absolutePath = '') => {
const host = 'default.net';
const path = '/';
const client = 'default';
const ssr = 'Default';
const confServer = DefaultConf.server[host][path];
const confClient = DefaultConf.client[client];
const confSsr = DefaultConf.ssr[ssr];
const clients = DefaultConf.client.default.services;
if (
absolutePath.match('src/api') &&
!absolutePath.match('src/api/types.js') &&
!confServer.apis.find((p) => absolutePath.match(`src/api/${p}/`))
) {
return false;
}
if (absolutePath.match('conf.dd-') && absolutePath.match('.js')) return false;
if (absolutePath.match('typedoc.dd-') && absolutePath.match('.json')) return false;
if (
absolutePath.match('src/client/services/') &&
!clients.find((p) => absolutePath.match(`src/client/services/${p}/`))
) {
return false;
}
if (absolutePath.match('src/client/public/') && !clients.find((p) => absolutePath.match(`src/client/public/${p}/`))) {
return false;
}
if (
absolutePath.match('src/client/components/') &&
!clients.find((p) => absolutePath.match(`src/client/components/${p}/`))
) {
return false;
}
if (absolutePath.match('src/client/sw/') && !absolutePath.match('src/client/sw/core.sw.js')) {
return false;
}
if (
absolutePath.match('src/client/ssr/body') &&
!confSsr.body.find((p) => absolutePath.match(`src/client/ssr/body/${p}.js`))
) {
return false;
}
if (
absolutePath.match('src/client/ssr/head') &&
!confSsr.head.find((p) => absolutePath.match(`src/client/ssr/head/${p}.js`))
) {
return false;
}
if (
absolutePath.match('src/client/ssr/mailer') &&
!Object.keys(confSsr.mailer).find((p) => absolutePath.match(`src/client/ssr/mailer/${confSsr.mailer[p]}.js`))
) {
return false;
}
if (
absolutePath.match('src/client/ssr/views') &&
!(confSsr.views || []).find((p) => absolutePath.match(`src/client/ssr/views/${p.client}.js`))
) {
return false;
}
if (absolutePath.match('hardhat/')) return false;
if (
absolutePath.match('/client') &&
absolutePath.match('.index.js') &&
!absolutePath.match('/offline') &&
!clients.find((p) => absolutePath.match(`src/client/${capFirst(p)}.index.js`))
) {
return false;
}
if (absolutePath.match('src/ws/') && !clients.find((p) => absolutePath.match(`src/ws/${p}/`))) {
return false;
}
return true;
};
/**
* @method awaitDeployMonitor
* @description Waits for the deploy monitor.
* @param {boolean} [isFinal=false] - If true, logs when the final (non-replica) deployment completes.
* @param {number} [deltaMs=1000] - The delta ms.
* @param {boolean} [callback=false] - The callback.
* @returns {Promise<boolean>} - `false` if `container-status=error` was detected, `true` on clean completion.
* @memberof ServerConfBuilder
*/
const awaitDeployMonitor = async (isFinal = false, deltaMs = 1000, callback = false) => {
if (!callback) Underpost.env.set('await-deploy', new Date().toISOString());
if (isFinal) logger.info('Final deployment running (no replica)');
await timer(deltaMs);
if (Underpost.env.get('container-status') === 'error') return false;
if (Underpost.env.get('await-deploy')) return await awaitDeployMonitor(false, deltaMs, true);
return true;
};
/**
* @method mergeFile
* @description Merges the file.
* @param {Array} parts - The parts.
* @param {string} outputFilePath - The output file path.
* @returns {Promise<void>} - The merge file.
* @memberof ServerConfBuilder
*/
const mergeFile = async (parts = [], outputFilePath) => {
await new Promise((resolve) => {
splitFile
.mergeFiles(parts, outputFilePath)
.then(() => {
resolve();
})
.catch((err) => {
console.log('Error: ', err);
resolve();
});
});
};
/**
* @method getPathsSSR
* @description Gets the paths SSR.
* @param {object} conf - The conf.
* @returns {Array} - The paths SSR.
* @memberof ServerConfBuilder
*/
const getPathsSSR = (conf) => {
const paths = ['src/client/ssr/RootDocument.js'];
for (const o of conf.head) paths.push(`src/client/ssr/head/${o}.js`);
for (const o of conf.body) paths.push(`src/client/ssr/body/${o}.js`);
for (const o of Object.keys(conf.mailer)) paths.push(`src/client/ssr/mailer/${conf.mailer[o]}.js`);
for (const o of conf.views || []) paths.push(`src/client/ssr/views/${o.client}.js`);
return paths;
};
/**
* @method splitFileFactory
* @description Splits the file factory.
* @param {string} name - The name.
* @param {string} _path - The path.
* @returns {Promise<boolean>} - The split file factory.
* @memberof ServerConfBuilder
*/
const splitFileFactory = async (name, _path) => {
const stats = fs.statSync(_path);
const maxSizeInBytes = 1024 * 1024 * 50; // 50 mb
const fileSizeInBytes = stats.size;
if (fileSizeInBytes > maxSizeInBytes) {
logger.info('splitFileFactory input', { name, from: _path });
return await new Promise((resolve) => {
splitFile
.splitFileBySize(_path, maxSizeInBytes) // 50 mb
.then((names) => {
logger.info('splitFileFactory output', { parts: names });
fs.writeFileSync(
`${_path.split('/').slice(0, -1).join('/')}/${name}-parths.json`,
JSON.stringify(names, null, 4),
'utf8',
);
fs.removeSync(_path);
return resolve(true);
})
.catch((err) => {
console.log('Error: ', err);
return resolve(false);
});
});
}
return false;
};
/**
* @method resolveReplicaCount
* @description Normalizes a CLI `--replicas` value to a positive integer, falling back to the
* caller's default when unset or invalid. Deliberately does not clamp upward: a floor would
* silently override an explicit, lower request, which is what made `--replicas 2` deploy three
* MongoDB members. Single source of truth so every statefulset reads the flag the same way.
* @param {string|number} input - Raw `--replicas` value.
* @param {number} [fallback=1] - Count to use when input is absent or not a positive integer.
* @returns {number} Effective replica count.
* @memberof ServerConfBuilder
*/
const resolveReplicaCount = (input, fallback = 1) => {
const parsed = Number.parseInt(input, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* @method generateSecurePassword
* @description Generates a cryptographically secure password satisfying every validatePassword
* constraint (lowercase, uppercase, digit, special character). Backed by `crypto.randomBytes`,
* never `Math.random`, because these values become long-lived service credentials.
* @param {number} [length=16] - Password length; values below 8 are raised to 8.
* @returns {string} The generated password.
* @memberof ServerConfBuilder
*/
const generateSecurePassword = (length = 16) => {
const size = Math.max(8, length);
const lower = 'abcdefghijklmnopqrstuvwxyz';
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const digits = '0123456789';
const special = '@#$%^&*()_+';
const all = lower + upper + digits + special;
const buf = crypto.randomBytes(size + 4);
// Guarantee at least one character from each required class
const chars = [
lower[buf[0] % lower.length],
upper[buf[1] % upper.length],
digits[buf[2] % digits.length],
special[buf[3] % special.length],
];
for (let i = 4; i < size; i++) chars.push(all[buf[i] % all.length]);
// Fisher-Yates shuffle using an independent random buffer
const shuf = crypto.randomBytes(size);
for (let i = chars.length - 1; i > 0; i--) {
const j = shuf[i % shuf.length] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
};
/**
* @method buildCliDoc
* @description Scrapes `node bin help` (and `node bin help <command>` for every
* registered command) and renders a structured Markdown reference: a command
* index with anchor links, plus a per-command section with its description,
* usage, and Arguments/Options rendered as tables. Writes
* `CLI-HELP.md` + the served reference doc, and refreshes the README CLI index.
* @param {object} program - The commander program.
* @param {string} oldVersion - The old version string to replace.
* @param {string} newVersion - The new version string.
* @memberof ServerConfBuilder
*/
const buildCliDoc = (program, oldVersion, newVersion) => {
const help = (args = '') => shellExec(`node bin help${args ? ` ${args}` : ''}`, { silent: true, stdout: true });
// Escape table-breaking pipes and collapse wrapped whitespace for a Markdown cell.
const cell = (s) => String(s).replace(/\s+/g, ' ').replaceAll('|', '\\|').trim();
const anchor = (name) => `underpost-${name}`.toLowerCase().replace(/[^a-z0-9-]/g, '');
// Parse a commander help block into { usage, description, sections: { Options, Arguments, Commands } }.
const parseHelp = (text) => {
const lines = text.split('\n');
const usageMatch = lines[0].match(/^Usage:\s*(.*)$/);
const usage = usageMatch ? usageMatch[1].trim() : '';
const sections = {};
const descLines = [];
let current = null;
let buf = [];
const flush = () => {
if (current) sections[current] = buf.join('\n');
buf = [];
};
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const head = line.match(/^([A-Za-z][\w ]*):\s*$/); // top-level "Options:", "Arguments:", "Commands:"
if (head) {
flush();
current = head[1].trim();
} else if (current !== null) {
buf.push(line);
} else {
descLines.push(line);
}
}
flush();
return { usage, description: descLines.join('\n').trim(), sections };
};
// Parse a columnar " <term> <description>" section (descriptions may wrap onto
// indented continuation lines) into [{ term, desc }].
const parseEntries = (text = '') => {
const entries = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
const leading = line.length - line.trimStart().length;
if (leading <= 2) {
const rest = line.trim();
const gap = rest.search(/\s{2,}/);
entries.push(gap === -1 ? { term: rest, desc: '' } : { term: rest.slice(0, gap), desc: rest.slice(gap) });
} else if (entries.length) {
entries[entries.length - 1].desc += ` ${line.trim()}`;
}
}
return entries;
};
const table = (head, entries) =>
!entries.length
? ''
: `| ${head[0]} | ${head[1]} |\n| --- | --- |\n` +
entries.map(({ term, desc }) => `| \`${cell(term)}\` | ${cell(desc)} |`).join('\n') +
'\n';
const detailSection = (sections, name, head) => {
const t = table(head, parseEntries(sections[name]));
return t ? `\n#### ${name}\n\n${t}` : '';
};
// ── Top-level index ──
const root = parseHelp(help());
const commandEntries = parseEntries(root.sections['Commands']).filter((e) => e.term.split(' ')[0] !== 'help');
const index =
`## Underpost CLI\n\n` +
(root.description ? `> ${root.description.replace(/\s+/g, ' ')}\n\n` : '') +
`**Usage:** \`${root.usage}\`\n\n` +
`### Global options\n\n${table(['Option', 'Description'], parseEntries(root.sections['Options']))}\n` +
`### Commands\n\n| Command | Description |\n| --- | --- |\n` +
commandEntries
.map((e) => {
const name = e.term.split(' ')[0];
return `| [\`${name}\`](#${anchor(name)}) | ${cell(e.desc)} |`;
})
.join('\n') +
'\n';
// ── Per-command detail ──
let details = `\n## Command reference\n`;
for (const cmd of program.commands) {
const name = cmd._name;
if (name === 'help') continue;
const cmdHelp = parseHelp(help(name));
details +=
`\n### underpost ${name}\n\n` +
(cmdHelp.description ? `${cmdHelp.description.replace(/\s+/g, ' ')}\n\n` : '') +
`**Usage:** \`${cmdHelp.usage}\`\n` +
detailSection(cmdHelp.sections, 'Arguments', ['Argument', 'Description']) +
detailSection(cmdHelp.sections, 'Options', ['Option', 'Description']) +
`\n---\n`;
}
const md = `${index}${details}`.replaceAll(oldVersion, newVersion);
fs.writeFileSync(`./src/client/public/nexodev/docs/references/Command Line Interface.md`, md, 'utf8');
fs.writeFileSync(`./CLI-HELP.md`, md, 'utf8');
// Update README.md: bump version and refresh the CLI index between the comment tags.
let readme = fs.readFileSync(`./README.md`, 'utf8').replaceAll(oldVersion, newVersion);
const cliStartTag = '<!-- cli-index-start -->';
const cliEndTag = '<!-- cli-index-end -->';
const startIdx = readme.indexOf(cliStartTag);
const endIdx = readme.indexOf(cliEndTag);
if (startIdx !== -1 && endIdx !== -1) {
const readmeIndex = index.replace(/\(#(underpost-[a-z0-9-]+)\)/g, '(CLI-HELP.md#$1)');
readme =
readme.substring(0, startIdx) +
cliStartTag +
'\n' +
readmeIndex.replaceAll(oldVersion, newVersion) +
'\n' +
cliEndTag +
readme.substring(endIdx + cliEndTag.length);
}
fs.writeFileSync('./README.md', readme, 'utf8');
};
/**
* @method getInstanceContext
* @description Gets the instance context.
* @param {object} options - The options.
* @param {string} options.deployId - The deploy ID.
* @param {boolean} options.singleReplica - The single replica.
* @param {Array} options.replicas - The replicas.
* @param {string} options.redirect - The redirect.
* @param {boolean} [options.peer=false] - Whether peer is enabled on the parent singleReplica path (used for port offset estimation when replica conf is not yet built).
* @returns {object} - The instance context.
* @memberof ServerConfBuilder
*/
const getInstanceContext = async (options = { deployId, singleReplica, replicas, redirect: '', peer: false }) => {
const { deployId, singleReplica, replicas, redirect, peer } = options;
let singleReplicaOffsetPortSum = 0;
if (singleReplica && replicas && replicas.length > 0) {
for (const replica of replicas) {
const replicaDeployId = buildReplicaId({ deployId, replica });
const replicaConfPath = `./engine-private/replica/${replicaDeployId}/conf.server.json`;
if (!fs.existsSync(replicaConfPath)) {
// Replica folder not built yet (e.g. dev mode without prior build);
// estimate port offset: 1 per replica path + 1 extra if peer is enabled on the parent singleReplica config
singleReplicaOffsetPortSum++;
if (peer) singleReplicaOffsetPortSum++;
continue;
}
const confReplicaServer = loadConfServerJson(replicaConfPath);
for (const host of Object.keys(confReplicaServer)) {
for (const path of Object.keys(confReplicaServer[host])) {
singleReplicaOffsetPortSum++;
if (confReplicaServer[host][path].peer) singleReplicaOffsetPortSum++;
}
}
}
}
const redirectTarget = redirect
? redirect[redirect.length - 1] === '/'
? redirect.slice(0, -1)
: redirect
: undefined;
return { redirectTarget, singleReplicaOffsetPortSum };
};
/**
* @method buildApiConf
* @description Builds the API configuration.
* @param {object} options - The options.
* @param {string} options.deployId - The deploy ID.
* @param {string} options.subConf - The sub configuration.
* @param {string} options.host - The host.
* @param {string} options.path - The path.
* @param {string} options.origin - The origin.
* @returns {object} - The API configuration.
* @memberof ServerConfBuilder
*/
const buildApiConf = async (options = { deployId: '', subConf: '', host: '', path: '', origin: '' }) => {
let { deployId, subConf, host, path, origin } = options;
if (!deployId) deployId = process.argv[2].trim();
if (!subConf) subConf = process.argv[3].trim();
if (process.argv[4]) host = process.argv[4].trim();
if (process.argv[5]) path = process.argv[5].trim();
if (process.argv[6])
origin = `${process.env.NODE_ENV === 'production' ? 'https' : 'http'}://${process.argv[6].trim()}`;
if (!origin) return;
const confServer = JSON.parse(
fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`, 'utf8'),
);
const envObj = dotenv.parse(
fs.readFileSync(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}`, 'utf8'),
);
if (host && path) {
confServer[host][path].origins = [origin];
logger.info('Build api conf', { host, path, origin });
} else return;
writeEnv(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-api`, envObj);
fs.writeFileSync(
`./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-api.json`,
JSON.stringify(confServer, null, 4),
'utf8',
);
};
/**
* @method buildClientStaticConf
* @description Builds the client static configuration.
* @param {object} options - The options.
* @param {string} options.deployId - The deploy ID.
* @param {string} options.subConf - The sub configuration.
* @param {string} options.apiBaseHost - The API base host.
* @param {string} options.host - The host.
* @param {string} options.path - The path.
* @param {boolean} options.devProxy - The dev proxy flag.
* @returns {void}
* @memberof ServerConfBuilder
*/
const buildClientStaticConf = async (
options = { deployId: '', subConf: '', apiBaseHost: '', host: '', path: '', devProxy: false },
) => {
let { deployId, subConf, host, path, devProxy } = options;
if (!deployId) deployId = process.argv[2].trim();
if (!subConf) subConf = process.argv[3].trim();
if (!host) host = process.argv[4].trim();
if (!path) path = process.argv[5].trim();
const confServer = JSON.parse(
fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-api.json`, 'utf8'),
);
const envObj = dotenv.parse(
fs.readFileSync(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-api`, 'utf8'),
);
envObj.PORT = parseInt(envObj.PORT);
const apiBaseHost = devProxy
? devProxyHostFactory({ host, tls: isTlsDevProxy() })
: options?.apiBaseHost
? options.apiBaseHost
: `localhost:${envObj.PORT + 1}`;
confServer[host][path].apiBaseHost = apiBaseHost;
confServer[host][path].apiBaseProxyPath = path;
logger.warn('Build client static conf', { host, path, apiBaseHost });
envObj.PORT = parseInt(confServer[host][path].origins[0].split(':')[2]) - 1;
writeEnv(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-client`, envObj);
fs.writeFileSync(
`./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-client.json`,
JSON.stringify(confServer, null, 4),
'utf8',
);
};
/**
* @method isDeployRunnerContext
* @description Checks if the deploy runner context is valid.
* @param {string} path - The path.
* @param {object} options - The options.
* @returns {boolean} - The deploy runner context.
* @memberof ServerConfBuilder
*/
const isDeployRunnerContext = (path, options) => !options.build && path && path !== 'template-deploy';
/**
* @method isDevProxyContext
* @description Checks if the dev proxy context is valid.
* @returns {boolean} - The dev proxy context.
* @memberof ServerConfBuilder
*/
const isDevProxyContext = () => (process.argv.find((arg) => arg === 'proxy') ? true : false);
/**
* @method devProxyHostFactory
* @description Creates the dev proxy host.
* @param {object} options - The options.
* @param {string} [options.host='default.net'] - The host.
* @param {boolean} [options.includeHttp=false] - Whether to include HTTP.
* @param {number} [options.port=443] - The port.
* @param {boolean} [options.tls=false] - Whether to use TLS.
* @returns {string} - The dev proxy host.
* @memberof ServerConfBuilder
*/
const devProxyHostFactory = (options = { host: 'default.net', includeHttp: false, port: 80, tls: false }) => {
const resolvedPort =
(options.port ? options.port : options.tls ? 443 : 80) + parseInt(process.env.DEV_PROXY_PORT_OFFSET);
const isDefaultPort = (options.tls && resolvedPort === 443) || (!options.tls && resolvedPort === 80);
const protocol = options.includeHttp ? (options.tls ? 'https://' : 'http://') : '';
const hostname = options.host ? options.host : 'localhost';
return `${protocol}${hostname}${isDefaultPort ? '' : `:${resolvedPort}`}`;
};
/**
* @method isTlsDevProxy
* @description Checks if TLS is used in the dev proxy.
* @returns {boolean} - The TLS dev proxy status.
* @memberof ServerConfBuilder
*/
const isTlsDevProxy = () => process.env.NODE_ENV !== 'production' && !!process.argv.find((arg) => arg === 'tls');
/**
* @method getTlsHosts
* @description Gets the TLS hosts.
* @param {object} confServer - The server configuration.
* @returns {Array} - The TLS hosts.
* @memberof ServerConfBuilder
*/
const getTlsHosts = (confServer) =>
Array.from(new Set(Object.keys(confServer).map((h) => new URL('https://' + h).hostname)));
/**
* Reads a `conf.server.json` file from disk, parses it, and resolves all `env:` secret
* references using {@link resolveConfSecrets}.
*
* Reads and parses a `conf.server.json` file from disk. The `env:` secret
* references are **preserved** by default so that build/deploy tooling never
* accidentally strips them. Callers that need the actual secret values
* (e.g. database or mailer modules) should explicitly wrap the result with
* {@link resolveConfSecrets}.
*
* @method loadConfServerJson
* @param {string} jsonPath - Absolute or relative path to the `conf.server.json` file.
* @param {object} [options] - Optional settings.
* @param {boolean} [options.resolve=false] - When `true`, resolves `env:` references
* via {@link resolveConfSecrets} before returning.
* @returns {object} The parsed server configuration object (secrets unresolved unless
* `options.resolve` is `true`).
* @throws {Error} If the file does not exist or cannot be parsed.
* @memberof ServerConfBuilder
*
* @example
* // Structure-only read (env: strings preserved)
* const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
*
* @example
* // Resolved read (env: strings replaced with process.env values)
* const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`, { resolve: true });
*/
const loadConfServerJson = (jsonPath, options) => {
if (!fs.existsSync(jsonPath)) {
throw new Error(`loadConfServerJson: configuration file not found: ${jsonPath}`);
}
const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
return options && options.resolve === true ? resolveConfSecrets(raw) : raw;
};
/**
* @method deepReplaceToken
* @description Recursively replaces every occurrence of `from` with `to` in all
* string leaves of a JSON-shaped value, returning a new structure.
* @param {*} value - Any JSON-serialisable value.
* @param {string} from - Token to replace.
* @param {string} to - Replacement token.
* @returns {*} A structurally identical value with the token replaced.
* @memberof ServerConfBuilder
*/
const deepReplaceToken = (value, from, to) => {
if (typeof value === 'string') return value.split(from).join(to);
if (Array.isArray(value)) return value.map((entry) => deepReplaceToken(entry, from, to));
if (value && typeof value === 'object')
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepReplaceToken(v, from, to)]));
return value;
};
/**
* @method readConfInstances
* @description Reads `conf.instances.json` and returns the bare array of instance
* entries. The canonical shape is a plain array; the historical `{ instances }`
* object wrapper is still unwrapped for backward compatibility. Multi-instance is
* declared per entry under `entry.multiInstance` (not deploy-wide).
* @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
* @returns {Array<object>} Instance entries.
* @memberof ServerConfBuilder
*/
const readConfInstances = (deployId) => {
const raw = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'));
return Array.isArray(raw) ? raw : raw.instances || [];
};
/**
* @method loadInstanceTopology
* @description Returns normalized variants describing the deploy's instance
* topology, or `null` when the deploy is single-instance. The root path is the
* default variant; no separate default code is declared.
* @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
* @returns {?{variants: Array<object>}} The topology, or `null`.
* @memberof ServerConfBuilder
*/
const loadInstanceTopology = (deployId) => {
for (const entry of readConfInstances(deployId)) {
const mi = entry.multiInstance;
if (mi && Array.isArray(mi.variants) && mi.variants.length > 0)
return normalizeInstanceTopology(mi, `${deployId}/${entry.id}`);
}
return null;
};
/**
* @method normalizeInstanceTopology
* @description Expands the compact `multiInstance.variants` path list into the
* descriptors used by deploy tooling. `/` is always the default and keeps the
* template workload id. `/FOREST` produces code `FOREST`, slug `/forest`, and
* path `/FOREST`. A missing root entry is prepended automatically.
* @param {{variants?: Array<string>}} spec - Multi-instance specification.
* @param {string} [context] - Configuration location used in validation errors.
* @returns {{variants: Array<{code:string,slug:string,path:string,isDefault:boolean}>}}
* @memberof ServerConfBuilder
*/
const normalizeInstanceTopology = (spec, context = 'multiInstance') => {
const declaredPaths = spec?.variants;
if (!Array.isArray(declaredPaths) || declaredPaths.length === 0) return { variants: [] };
if (declaredPaths.some((path) => typeof path !== 'string'))
throw new Error(`${context}: multiInstance.variants must contain only path strings`);
if (new Set(declaredPaths).size !== declaredPaths.length)
throw new Error(`${context}: multiInstance.variants contains a duplicate path`);
const paths = ['/', ...declaredPaths.filter((path) => path !== '/')];
const variants = paths.map((path) => {
if (path !== '/' && !/^\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(path))
throw new Error(`${context}: invalid instance variant path "${path}"`);
const code = path.slice(1);
return { code, slug: path === '/' ? '' : path.toLowerCase(), path, isDefault: path === '/' };
});
const seen = new Set();
for (const variant of variants) {
if (seen.has(variant.slug)) throw new Error(`${context}: duplicate instance variant "${variant.path}"`);
seen.add(variant.slug);
}
return { variants };
};
/**
* @method dispatchBuildInstanceEnv
* @description Applies an optional deploy-specific env builder to a canonical
* env object. Generic topology code never knows project env key names; callers
* register builders by deploy id. The runner-owned container id is applied last.
* @param {object} options - Dispatch context.
* @param {string} options.deployId - Deployment id used to select a builder.
* @param {object} options.instance - Expanded instance descriptor.
* @param {string} options.environment - development or production.
* @param {Object<string,string>} options.baseEnv - Parsed canonical env file.
* @param {string} options.containerDeployId - Runner-derived deployment id.
* @param {Object<string,Function>} [options.builders] - Deploy id to env builder registry.
* @returns {Object<string,string>} Complete materialized env object.
* @memberof ServerConfBuilder
*/
const dispatchBuildInstanceEnv = ({
deployId,
instance,
environment,
baseEnv = {},
containerDeployId,
builders = {},
}) => {
const builder = builders[deployId];
const env = builder ? builder({ deployId, instance, environment, env: { ...baseEnv } }) : { ...baseEnv };
if (!env || typeof env !== 'object' || Array.isArray(env))
throw new TypeError(`dispatchBuildInstanceEnv: builder for "${deployId}" must return an env object`);
return { ...env, CONTAINER_DEPLOY_ID: containerDeployId };
};
/**
* Loads a deploy project's optional instance env builder by convention.
* `dd-cyberia` resolves to `src/projects/cyberia/instance-data.js`, whose
* public integration export is `buildInstanceEnv`. Missing modules mean the
* canonical env is copied unchanged; malformed exports fail explicitly.
* @param {string} deployId - Deployment id in `dd-<project>` form.
* @returns {Promise<Function|null>} Project env builder, when provided.
* @memberof ServerConfBuilder
*/
const loadProjectInstanceEnvBuilder = async (deployId) => {
const match = /^dd-([a-z0-9][a-z0-9-]*)$/.exec(`${deployId || ''}`);
if (!match) return null;
const moduleUrl = new URL(`../projects/${match[1]}/instance-data.js`, import.meta.url);
if (!fs.existsSync(moduleUrl)) return null;
const projectModule = await import(moduleUrl.href);
if (projectModule.buildInstanceEnv === undefined) return null;
if (typeof projectModule.buildInstanceEnv !== 'function')
throw new TypeError(`${moduleUrl.pathname}: buildInstanceEnv must be a function`);
return projectModule.buildInstanceEnv;
};
/**
* @method loadConfInstances
* @description Loads `conf.instances.json` and expands every entry carrying a
* `multiInstance` block into one concrete instance per declared variant.
*
* A template entry is never deployed as-is once variants exist: each variant
* produces its own entry whose id, env file path, volume mount and
* container-status strings are derived by replacing the template id token
* throughout. The `/` variant keeps the template id verbatim, so pre-existing
* deployments, PVCs and env directories survive multi-instance expansion.
*
* Nothing here is application-specific: variants preserve their public path
* through the ingress and the runtime owns that base-path contract.
* Project-specific env behavior is delegated through
* {@link dispatchBuildInstanceEnv} rather than encoded in topology configuration.
*
* Every expanded entry carries normalized metadata consumed by deploy runners:
* `instanceCode`, `instanceSlug`, `isDefaultInstance`, and `templateId`.
*
* @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
* @returns {Array<object>} Expanded instance entries.
* @memberof ServerConfBuilder
*/
const loadConfInstances = (deployId) => {
const expanded = [];
for (const entry of readConfInstances(deployId)) {
const spec = entry.multiInstance;
if (!Array.isArray(spec?.variants) || spec.variants.length === 0) {
expanded.push(entry.path ? entry : { ...entry, path: '/' });
continue;
}
if (Object.hasOwn(spec, 'env'))
throw new Error(
`loadConfInstances: ${deployId}/${entry.id} uses removed multiInstance.env; ` +
'move project-specific env logic to a dispatch env builder',
);
const topology = normalizeInstanceTopology(spec, `${deployId}/${entry.id}`);
const variants = topology.variants;
for (const variant of variants) {
const id = variant.isDefault ? entry.id : `${entry.id}-${variant.slug.slice(1)}`;
const instance = variant.isDefault ? JSON.parse(JSON.stringify(entry)) : deepReplaceToken(entry, entry.id, id);
delete instance.multiInstance;
instance.id = id;
instance.path = variant.path;
instance.instanceCode = variant.code;
instance.instanceSlug = variant.slug;
instance.isDefaultInstance = variant.isDefault;
instance.templateId = entry.id;
expanded.push(instance);
}
}
return expanded;
};
/**
* @method selectConfInstances
* @description Filters expanded instances for a deploy target. Targeting a
* template id (`mmo-server`) selects the whole instance family; targeting a
* concrete id (`mmo-server-forest`) selects just that one.
* @param {Array<object>} instances - Expanded entries from {@link loadConfInstances}.
* @param {string} id - Target instance id or template id.
* @returns {Array<object>} Matching entries.
* @memberof ServerConfBuilder
*/
const selectConfInstances = (instances, id) =>
instances.filter((instance) => instance.id === id || instance.templateId === id);
/**
* @method resolveEnvScoped
* @description Resolves a conf value that may be declared either shared or
* env-scoped. An instance block is written as `{ ...spec }` when both
* environments share it and as `{ development: {...}, production: {...} }` when
* they do not; both shapes reach the manifest factories, which expect the
* resolved one.
* @param {object|undefined} value - Shared or env-scoped block.
* @param {string} env - `development` | `production`.
* @returns {object|undefined} The block for `env`, or the value unchanged when it is shared.
* @memberof ServerConfBuilder
*/
const resolveEnvScoped = (value, env) => (value && (value.development || value.production) ? value[env] : value);
/**
* @method instancePortFactory
* @description The port an instance is reached on for an environment.
* Development prefers the instance's debug port when it declares one, so a
* local runtime can expose a debugger without the production port moving.
* @param {object} instance - Expanded instance entry.
* @param {string} env - `development` | `production`.
* @param {boolean} [container] - Resolve the container-side port (`toPort`) instead of the proxied one (`fromPort`).
* @returns {number|undefined} The effective port.
* @memberof ServerConfBuilder
*/
const instancePortFactory = ({ instance, env, container = false }) => {
const [port, debugPort] = container
? [instance.toPort, instance.toDebugPort]
: [instance.fromPort, instance.fromDebugPort];
return env === 'development' && debugPort ? debugPort : port;
};
/**
* @method sortInstancesByPath
* @description Orders instances longest sub-path first, so a specific instance
* path (`/FOREST`) is never shadowed by the default instance's catch-all (`/`).
* @param {Array<object>} instances - Expanded instance entries.
* @returns {Array<object>} A new ordered array.
* @memberof ServerConfBuilder
*/
const sortInstancesByPath = (instances) =>
[...instances].sort((a, b) => (b.path || '/').length - (a.path || '/').length);
/**
* @method deployHostsFactory
* @description Every hostname a deploy terminates: the ones its `conf.server.json`
* serves directly, plus the ones its `conf.instances.json` instances serve.
*
* Both sets reach the browser through the one Gateway the deploy owns, so both
* have to appear in its listener's certificate list and in the `/etc/hosts` pass
* — an instance host missing from either is unreachable even though its workload
* and route are correct.
*
* Every declared hostname is returned, not just the ones being deployed right
* now: the Gateway is per deploy, not per run, and a certificate reference it
* carries for a hostname the environment never provisioned is unresolvable —
* which costs the listener, and with it every other hostname on it.
* @param {string} deployId - Deployment identifier.
* @returns {Array<string>} Unique hostnames, deploy hosts first.
* @memberof ServerConfBuilder
*/
const deployHostsFactory = (deployId) => {
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
const serverHosts = fs.existsSync(confServerPath) ? Object.keys(loadConfServerJson(confServerPath)) : [];
const instanceHosts = fs.existsSync(confInstancesPath)
? loadConfInstances(deployId).map((instance) => instance.host)
: [];
return [...new Set([...serverHosts, ...instanceHosts].filter(Boolean))];
};
/**
* @method instanceStatusPageDeployIdFactory
* @description Status page documents are identical across a family's variants,
* so the resources holding them are named after the template instance and
* shared by every variant route.
* @param {string} deployId - Parent deployment identifier.
* @param {object} instance - Expanded instance entry.
* @returns {string} Family-scoped deploy id owning the status page resources.
* @memberof ServerConfBuilder
*/
const instanceStatusPageDeployIdFactory = (deployId, instance) => `${deployId}-${instance.templateId || instance.id}`;
/**
* @method instanceProjectPathFactory
* @description Where an instance's own project sits in this checkout. A
* `customStatusPages` entry declares `hostPath` relative to that project
* (`./public/404/index.html`), not to the engine root, because the document is
* built and versioned by the project the instance runs.
*
* The directory is the repository's own name — the same name `underpost clone`
* checks it out under — falling back to the runtime and then the instance id.
* @param {object} instance - Expanded instance entry.
* @returns {string} Project root, relative to the engine root.
* @memberof ServerConfBuilder
*/
const instanceProjectPathFactory = (instance) =>
`./${`${instance?.metadata?.repository || instance?.runtime || instance?.id || ''}`.split('/').pop()}`;
/**
* @method instanceStatusPageEntriesFactory
* @description Resolves every `customStatusPages` entry an instance declares
* into the document to copy and the place under the static root to copy it to.
*
* Single source of truth for the two sides that must agree exactly: the
* destination comes from the same {@link UnderpostGateway.statusPageAssetPathFactory}
* the HTTPRoute rewrites to, so a variant's page lands where that variant's rule
* points — `/FOREST/404` at `<host>/FOREST/status-pages/404/index.html`, and the
* default variant at `<host>/root/status-pages/404/index.html`.
* @param {Array<object>} instances - Expanded instance entries.
* @param {string} [projectPath] - Project root override; omit to derive one per instance.
* @returns {Array<{host: string, path: string, status: string, assetPath: string, sourcePath: string}>}
* One entry per declared page, skipping entries missing a status or a source.
* @memberof ServerConfBuilder
*/
const instanceStatusPageEntriesFactory = ({ instances = [], projectPath }) =>
instances.flatMap((instance) =>
(instance.customStatusPages || [])
.filter((page) => page?.status && page?.hostPath)
.map((page) => ({
host: instance.host,
path: instance.path,
status: `${page.status}`,
assetPath: statusPageAssetPathFactory({ host: instance.host, path: instance.path, status: page.status })
.assetPath,
sourcePath: dir.normalize(`${projectPath || instanceProjectPathFactory(instance)}/${page.hostPath}`),
})),
);
/**
* @method nextTrafficFactory
* @description The colour a promote routes to next.
*
* The single definition of the blue/green flip. An explicit request wins; with no
* colour live yet the canonical first colour is `blue`.
* @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
* @param {string} [requestedTraffic] - Explicitly requested colour, if any.
* @returns {string} `blue` or `green`.
* @memberof ServerConfBuilder
*/
const nextTrafficFactory = (liveTraffic = '', requestedTraffic = '') =>
requestedTraffic === 'blue' || requestedTraffic === 'green'
? requestedTraffic
: liveTraffic === 'blue'
? 'green'
: 'blue';
/**
* @method schedulableNodeFactory
* @description Narrows a chosen node name to one the cluster actually has.
*
* Node defaults are guessed from the environment when no cluster flag is given —
* `development` implies a kind cluster and therefore `kind-worker`. That guess is
* wrong on a `--dev` kubeadm cluster, and for a `hostNetwork` listener pinned by
* `nodeSelector` it is fatal rather than merely suboptimal: nothing schedules,
* and the name persists in the live object so every later run inherits it.
*
* A control-plane node is the last resort, not the first: on a multi-node cluster
* the public listener belongs on a worker. With no node list to check against the
* caller's choice is returned untouched — an unreadable cluster is not evidence
* the name is wrong.
* @param {Array<object>} [nodes] - Rows from `kubectl get nodes` (NAME, STATUS, ROLES).
* @param {string} [node] - The chosen node name.
* @returns {{node: string, corrected: boolean}} The name to use, and whether it had to change.
* @memberof ServerConfBuilder
*/
const schedulableNodeFactory = ({ nodes = [], node = '' }) => {
const named = nodes.filter((entry) => entry?.NAME);
if (named.length === 0) return { node, corrected: false };
if (node && named.some((entry) => entry.NAME === node)) return { node, corrected: false };
// STATUS can be a comma-joined list (e.g. "Ready,SchedulingDisabled").
const ready = named.filter((entry) => `${entry.STATUS || ''}`.split(',').includes('Ready'));
const pool = ready.length > 0 ? ready : named;
const worker = pool.find((entry) => !`${entry.ROLES || ''}`.includes('control-plane'));
return { node: (worker || pool[0]).NAME, corrected: true };
};
/**
* @method stopPlanFactory
* @description Resolves which colour-suffixed Deployments a stop should remove.
*
* Four ways to name them, in precedence order:
*
* 1. A literal comma path names the Deployments outright and every flag is
* ignored — the caller already knows the exact object, so nothing is derived
* and nothing else can be caught by accident.
* 2. `deployId` alone selects that deploy's PWA workload.
* 3. `deployId` with `instanceId` adds every custom instance of each id; a
* template id expands to its whole variant family, since each variant is its
* own Deployment.
* 4. `instanceId` without `deployId` is refused: an instance id is only unique
* inside a deploy, so acting on it alone would be a guess.
*
* Colour selection is separate: `traffic` names the colours explicitly (a comma
* list, so `blue,green` stops both), and without it each target resolves to the
* blue/green partner of whatever it is currently serving — the colour that is by
* definition not carrying traffic.
* @param {string} [path] - Literal comma-separated Deployment names.
* @param {string} [deployId] - Deploy id whose workload and instances are targeted.
* @param {string} [instanceId] - Comma-separated instance or template ids.
* @param {string} [traffic] - Comma-separated colours; empty means the inactive one.
* @param {string} [env] - `development` | `production`.
* @param {Function} [instancesFor] - `(instanceId) => Array<object>` expanded instances.
* @param {Function} [liveTrafficOf] - `(target) => 'blue' | 'green' | '' | null`.
* @returns {{deployments: Array<object>, error: string|null}} The plan, or why there isn't one.
* @memberof ServerConfBuilder
*/
const stopPlanFactory = ({
path = '',
deployId = '',
instanceId = '',
traffic = '',
env = '',
instancesFor = () => [],
liveTrafficOf = () => '',
}) => {
const list = (value) =>
`${value || ''}`
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const literal = list(path);
if (literal.length > 0)
return {
deployments: literal.map((deployment) => ({ deployment, kind: 'literal', id: deployment, host: '', colour: '' })),
error: null,
};
const instanceIds = list(instanceId);
if (!deployId)
return {
deployments: [],
error:
instanceIds.length > 0
? '--instance-id requires --deploy-id: an instance id is only unique inside a deploy'
: 'nothing to stop: pass a literal deployment path, or --deploy-id',
};
const requestedRaw = list(traffic);
const requested = requestedRaw.filter((colour) => colour === 'blue' || colour === 'green');
if (requestedRaw.length > 0 && requested.length === 0)
return { deployments: [], error: `--traffic accepts blue and/or green, got: ${requestedRaw.join(',')}` };
const targets = [{ id: deployId, host: '', kind: 'pwa' }];
for (const id of instanceIds)
for (const instance of instancesFor(id))
targets.push({ id: `${deployId}-${instance.id}`, host: instance.host || '', kind: 'instance' });
const deployments = [];
const seen = new Set();
for (const target of targets)
for (const colour of requested.length > 0 ? requested : [nextTrafficFactory(liveTrafficOf(target))]) {
const deployment = `${target.id}-${env}-${colour}`;
if (seen.has(deployment)) continue;
seen.add(deployment);
deployments.push({ ...target, colour, deployment });
}
return { deployments, error: null };
};
/**
* @method trafficFromRoutingInfoFactory
* @description Reads a deployment's live colour out of the routing text that
* carries it.
*
* Legacy stacks name the backend Service `<deployId>-<env>-<colour>-service`;
* the stable traffic Service names the same value in `spec.selector.app` without
* the `-service` suffix. The colour therefore reads the same way during and
* after migration. Kept pure and separate
* from the read so one host's routing text can be matched against several
* deployments and environments without fetching it again.
*
* With `env` given the match is anchored on the full `<deployId>-<env>-` prefix:
* essential on a shared multi-instance host, where one object holds several
* variants' routes on possibly different colours, and where an unanchored match
* would return a sibling's answer. `dd-cyberia-mmo-server` never matches
* `dd-cyberia-mmo-server-forest` this way.
* @param {string} [info] - Routing text (route object YAML and/or Nginx block).
* @param {string} deployId - Deployment identifier the colour is wanted for.
* @param {string} [env] - `development` | `production`; omitted falls back to a whole-text match.
* @returns {string|null} `blue`, `green`, or null when the text names neither.
* @memberof ServerConfBuilder
*/
const trafficFromRoutingInfoFactory = ({ info = '', deployId = '', env = '' }) => {
if (!`${info}`.trim()) return null;
if (env) {
const escaped = `${deployId}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = `${info}`.match(new RegExp(`${escaped}-${env}-(blue|green)(?:-service)?(?:\\s|$)`));
return match ? match[1] : null;
}
return `${info}`.match('blue') ? 'blue' : `${info}`.match('green') ? 'green' : null;
};
/**
* @method trafficProbePathsFactory
* @description The literal paths a conf.server.json route should be probed on,
* matching the `replicas`/`singleReplica` convention `loadReplicas` expands for
* a real build (see push-bundle/pull-bundle): a `singleReplica` route is never
* itself served, so probing its canonical path always reads as unrouted; a
* plain `replicas` route serves the canonical path in addition to each replica.
* Read-only by design — a traffic report must not mutate conf.server.json as a
* side effect of being read, unlike `loadReplicas`.
* @param {object} [routeConf] - `confServer[host][path]` entry.
* @param {string} routePath - The canonical path key.
* @returns {Array<string>} Paths to probe for this route.
* @memberof ServerConfBuilder
*/
const trafficProbePathsFactory = (routeConf = {}, routePath) => {
const replicas = Array.isArray(routeConf.replicas) ? routeConf.replicas : [];
if (replicas.length === 0) return [routePath];
return routeConf.singleReplica ? replicas : [routePath, ...replicas];
};
/**
* @method deployTrafficEntriesFactory
* @description Every routable deployment a deploy id owns, of both kinds.
*
* The two kinds are named differently in the cluster and resolved from different
* conf files, which is exactly why a colour report has to build them together:
* the PWA workload is one Deployment per deploy id serving whatever hosts its
* `conf.server.json` declares, while each expanded custom instance is its own
* Deployment behind one host sub-path.
* @param {string} deployId - Deployment identifier.
* @param {string} env - `development` | `production`.
* @returns {Array<{kind: string, deployId: string, id: string, host: string, path: string, deployment: string}>} One entry per routable deployment.
* @memberof ServerConfBuilder
*/
const deployTrafficEntriesFactory = ({ deployId, env }) => {
const entries = [];
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
if (fs.existsSync(confServerPath)) {
const confServer = loadConfServerJson(confServerPath);
for (const host of Object.keys(confServer))
entries.push({
kind: 'pwa',
deployId,
id: deployId,
host,
path:
Object.keys(confServer[host])
.flatMap((routePath) => trafficProbePathsFactory(confServer[host][routePath], routePath))
.join(' ') || '/',
deployment: `${deployId}-${env}`,
});
}
if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`))
for (const instance of loadConfInstances(deployId))
entries.push({
kind: 'instance',
deployId,
id: `${deployId}-${instance.id}`,
host: instance.host,
path: instance.path || '/',
deployment: `${deployId}-${instance.id}-${env}`,
});
return entries;
};
/**
* @method hostIngressFactsFactory
* @description What the cluster's routing objects say about each hostname: which
* kind describes it, whether it is served over TLS, and whether HTTP/3 is on.
*
* None of the three can be read from the route object alone. The kind is which
* object exists; TLS lives on the Gateway's listener (an HTTPRoute never carries
* it) or on an HTTPProxy's `virtualhost.tls`; and HTTP/3 is a `ClientTrafficPolicy`
* targeting that Gateway. So the answer is a correlation across four kinds, done
* once for the whole cluster rather than per row.
*
* A hostname described by both kinds is a leftover from switching stacks; the
* HTTPRoute wins, matching the precedence the shared ingress routes it with.
* @param {Array<object>} [httpRoutes] - HTTPRoute items.
* @param {Array<object>} [httpProxies] - HTTPProxy items.
* @param {Array<object>} [gateways] - Gateway items.
* @param {Array<object>} [clientTrafficPolicies] - ClientTrafficPolicy items.
* @returns {Object<string,{route: string, tls: boolean, http3: boolean}>} Facts by hostname.
* @memberof ServerConfBuilder
*/
const hostIngressFactsFactory = ({
httpRoutes = [],
httpProxies = [],
gateways = [],
clientTrafficPolicies = [],
} = {}) => {
const tlsGateways = new Set(
gateways
.filter((gateway) =>
(gateway?.spec?.listeners || []).some((listener) => `${listener?.protocol}`.toUpperCase() === 'HTTPS'),
)
.map((gateway) => gateway?.metadata?.name)
.filter(Boolean),
);
// QUIC only exists where TLS does, so a policy naming a Gateway with no HTTPS
// listener describes nothing — the same reason the policy is emitted scoped to
// the HTTPS section in the first place.
const http3Gateways = new Set(
clientTrafficPolicies
.filter((policy) => policy?.spec?.http3 !== undefined)
.flatMap((policy) => [...(policy?.spec?.targetRefs || []), policy?.spec?.targetRef].filter(Boolean))
.map((ref) => ref?.name)
.filter((name) => name && tlsGateways.has(name)),
);
const facts = {};
for (const proxy of httpProxies) {
const host = proxy?.spec?.virtualhost?.fqdn;
if (!host) continue;
facts[host] = { route: 'HTTPProxy', tls: !!proxy?.spec?.virtualhost?.tls, http3: false };
}
for (const route of httpRoutes) {
const parents = (route?.spec?.parentRefs || []).map((ref) => ref?.name).filter(Boolean);
const tls = parents.some((name) => tlsGateways.has(name));
const http3 = parents.some((name) => http3Gateways.has(name));
for (const host of route?.spec?.hostnames || []) if (host) facts[host] = { route: 'HTTPRoute', tls, http3 };
}
return facts;
};
/**
* @method curlStatusChainFactory
* @description Extracts the response chain emitted by `curl -L -v -i -s`.
* Verbose response lines are authoritative because `-i` can duplicate the same
* headers on stdout. A write-out marker supplies the final code when curl did
* not emit a verbose response (and `000` when no HTTP response was received).
* CONNECT tunnel acknowledgements are transport setup, not host responses, and
* are deliberately excluded from the displayed chain.
* @param {string} [raw] - Combined curl stdout/stderr.
* @returns {Array<string>} Ordered three-digit response codes.
* @memberof ServerConfBuilder
*/
const curlStatusChainFactory = (raw = '') => {
const text = `${raw || ''}`;
const verbose = [...text.matchAll(/^< HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
(match) => match[1],
);
const headers = [...text.matchAll(/^HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
(match) => match[1],
);
const chain = verbose.length > 0 ? verbose : headers;
const finalCode = /UNDERPOST_CURL_FINAL=([0-9]{3})/.exec(text)?.[1] || '';
if (finalCode && finalCode !== '000' && chain[chain.length - 1] !== finalCode) chain.push(finalCode);
if (chain.length === 0) chain.push(finalCode || '000');
return chain;
};
/**
* @method trafficTableRowsFactory
* @description Resolves the live colour of each routable deployment, optionally
* narrowed to a set of hosts.
*
* Cluster lookups are injected so the shaping stays a pure resolution over the
* conf. An entry whose colour cannot be read is reported with an empty colour
* rather than dropped — "no route published" is the answer, not an absence.
* @param {Array<object>} [entries] - Entries from {@link ServerConfBuilder.deployTrafficEntriesFactory}.
* @param {Array<string>} [hosts] - Hosts to report on; empty reports every host.
* @param {Function} liveTrafficOf - `(entry) => 'blue' | 'green' | '' | null`.
* @param {Function} servesTraffic - `(entry, colour) => boolean`.
* @returns {Array<object>} Entries with `traffic` and `serving` resolved.
* @memberof ServerConfBuilder
*/
const trafficTableRowsFactory = ({
entries = [],
hosts = [],
liveTrafficOf = () => '',
servesTraffic = () => false,
}) => {
const wanted = new Set(hosts.filter(Boolean));
return entries
.filter((entry) => wanted.size === 0 || wanted.has(entry.host))
.map((entry) => {
const traffic = liveTrafficOf(entry) || '';
return {
...entry,
traffic,
serving: isTrafficServingFactory({
liveTraffic: traffic,
hasReadyEndpoints: (colour) => servesTraffic(entry, colour),
}),
};
});
};
/**
* @method hostRenderInstancesFactory
* @description The instances a shared host's routing must be rendered from.
*
* A host's Nginx server block and its HTTPRoute are single objects shared by
* every variant on that host, so both are rewritten whole on every promote.
* Rendering them from the declared set alone therefore deletes the routes of any
* variant that is still deployed but no longer declared — silently taking down a
* workload this deploy was never asked to touch.
*
* Each variant sub-path is its own deployment, so a variant loses its route only
* once its workload is actually gone. `preserved` carries the descriptors last
* published for this host; a declared entry always wins, since it is the current
* truth for that id.
* @param {Array<object>} [declared] - Instances the conf declares for this host.
* @param {Array<object>} [preserved] - Instances last published for this host.
* @param {Function} [isDeployed] - `(instance) => boolean`, true while its workload exists.
* @returns {Array<object>} Declared entries, plus still-deployed preserved ones.
* @memberof ServerConfBuilder
*/
const hostRenderInstancesFactory = ({ declared = [], preserved = [], isDeployed = () => false }) => {
const declaredIds = new Set(declared.map((instance) => instance.id));
return [
...declared,
...preserved.filter((instance) => instance?.id && !declaredIds.has(instance.id) && isDeployed(instance)),
];
};
/**
* @method isTrafficServingFactory
* @description Whether a routed colour is actually carrying traffic.
*
* The one gate every no-backend fallback checkpoint is conditional on, shared by
* `run sync` and `run instance`. A colour is serving only when it is both routed
* and still has a ready endpoint: a route can name a colour whose workload is
* long gone, and taking a host offline to prove a fallback is only acceptable
* when nothing is serving it.
* @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
* @param {Function} hasReadyEndpoints - `(colour) => boolean`.
* @returns {boolean} True when that colour is routed and reachable.
* @memberof ServerConfBuilder
*/
const isTrafficServingFactory = ({ liveTraffic = '', hasReadyEndpoints = () => false }) =>
!!liveTraffic && hasReadyEndpoints(liveTraffic);
/**
* @method instanceTrafficPlanFactory
* @description Resolves, for each instance, the colour routed now and the colour
* to route next, and which instances are actually serving on the live one.
*
* `serving` is the precondition for the no-backend fallback checkpoint, which
* routes the edge at a colour that has no Deployment yet. That is correct on a
* first bring-up and an outage on a re-deploy, and a routed colour is only real
* traffic when it still has a ready endpoint — a route alone can name a colour
* whose workload is long gone.
*
* Cluster lookups are injected so this stays a pure resolution over the conf.
* @param {Array<object>} [instances] - Expanded instance entries.
* @param {string} [requestedTraffic] - Explicitly requested colour, if any.
* @param {Function} liveTrafficOf - `(instance) => 'blue' | 'green' | '' | null`.
* @param {Function} servesTraffic - `(instance, colour) => boolean`, true when that colour has a ready endpoint.
* @returns {{liveTrafficById: Object<string,string>, targetTrafficById: Object<string,string>, serving: Array<object>}} The plan.
* @memberof ServerConfBuilder
*/
const instanceTrafficPlanFactory = ({
instances = [],
requestedTraffic = '',
liveTrafficOf = () => '',
servesTraffic = () => false,
}) => {
const liveTrafficById = {};
const targetTrafficById = {};
const serving = [];
for (const instance of instances) {
const liveTraffic = liveTrafficOf(instance) || '';
liveTrafficById[instance.id] = liveTraffic;
targetTrafficById[instance.id] = nextTrafficFactory(liveTraffic, requestedTraffic);
if (isTrafficServingFactory({ liveTraffic, hasReadyEndpoints: (colour) => servesTraffic(instance, colour) }))
serving.push(instance);
}
return { liveTrafficById, targetTrafficById, serving };
};
/**
* @method instanceInterceptStatusesFactory
* @description The statuses the gateway intercepts for one instance, and the
* context directory each is answered from.
*
* Driven entirely by what the instance declares: every `customStatusPages` entry
* answers its own status, and those declarations are also what upstream-failure
* codes fall back to — a variant whose workload is gone should show the same page
* as one that has no such route, because to the client they are the same thing.
* @param {object} instance - Expanded instance entry.
* @returns {Object<string,string>} Status code → context directory under the instance sub-path.
* @memberof ServerConfBuilder
*/
const instanceInterceptStatusesFactory = (instance) => {
const statuses = {};
for (const page of instance?.customStatusPages || []) {
if (!page?.status || !page?.hostPath) continue;
const context = `status-pages/${page.status}`;
statuses[page.status] = context;
// Custom instances do not have the PWA's `maintenanceDefault` SSR view.
// Their declared status document is therefore also the only useful answer
// while the binary is absent or still becoming Ready. Nginx preserves the
// original 502/503/504 code while substituting this document, so clients
// can distinguish an unavailable runtime from the instance's own 404.
for (const failureStatus of [502, 503, 504]) if (!statuses[failureStatus]) statuses[failureStatus] = context;
}
return statuses;
};
/**
* @method instanceProxyRoutesFactory
* @description Renders the Contour HTTPProxy route block for every instance
* sharing a host.
* @param {string} deployId - Parent deployment identifier.
* @param {Array<object>} instances - Expanded instance entries bound to one host.
* @param {string} env - `development` | `production`.
* @param {Object<string,string>} trafficById - Instance id → traffic colour.
* @returns {string} Concatenated route YAML.
* @memberof ServerConfBuilder
*/
const instanceProxyRoutesFactory = ({ deployId, instances, env, trafficById }) =>
sortInstancesByPath(instances)
.map((instance) =>
Underpost.deploy.deploymentYamlServiceFactory({
path: instance.path,
port: instancePortFactory({ instance, env }),
serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
pathRewritePolicy: instance.pathRewritePolicy,
}),
)
.join('');
/**
* @method instanceHttpRouteRulesFactory
* @description Renders the Gateway API rules for every instance sharing a host:
* the workload rule for each instance sub-path, plus the edge-served status page
* rules declared by that instance's `customStatusPages`.
*
* Variant paths are preserved by default, so the selected runtime receives the
* same URL that the client requested. An explicit generic `pathRewritePolicy`
* is still passed through for unrelated workloads that define one directly.
* @param {string} deployId - Parent deployment identifier.
* @param {Array<object>} instances - Expanded instance entries bound to one host.
* @param {string} env - `development` | `production`.
* @param {Object<string,string>} trafficById - Instance id → traffic colour.
* @param {object} [options] - Runner options (namespace, gateway/QUIC settings).
* @param {Array<string>} [servedStatuses] - Statuses whose document reached the static tree; undefined means all declared.
* @returns {string} Concatenated rule YAML.
* @memberof ServerConfBuilder
*/
const instanceHttpRouteRulesFactory = ({ deployId, instances, env, trafficById, options, servedStatuses }) => {
const { http3, altSvc } = Underpost.deploy.gatewayApiConfigFactory(options);
const sorted = sortInstancesByPath(instances);
// The `/` status fallback is only emitted when no workload claims the root
// path: two rules matching `/` would leave gateway precedence ambiguous.
const rootClaimed = sorted.some((instance) => (instance.path || '/') === '/');
let rules = '';
for (const [i, instance] of sorted.entries()) {
rules += Underpost.deploy.statusPageRouteRulesFactory({
deployId: instanceStatusPageDeployIdFactory(deployId, instance),
host: instance.host,
basePath: instance.path,
statusPages: instance.customStatusPages,
altSvc: http3 ? altSvc : undefined,
catchAll: !rootClaimed && i === sorted.length - 1,
servedStatuses,
});
// An instance that declares a status page is reached through the shared
// gateway, which proxies to its workload and intercepts the errors. One that
// declares none is routed straight there, so the extra hop only exists where
// it buys something. Any explicit generic `pathRewritePolicy` moves to the
// gateway with the workload route.
const intercepted = Object.keys(instanceInterceptStatusesFactory(instance)).length > 0;
rules += Underpost.deploy.httpRouteRuleFactory({
path: instance.path,
...(intercepted
? { serviceId: UNDERPOST_GATEWAY.serviceName, port: UNDERPOST_GATEWAY.port }
: {
port: instancePortFactory({ instance, env }),
serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
pathRewritePolicy: instance.pathRewritePolicy,
}),
altSvc: http3 ? altSvc : undefined,
});
}
return rules;
};
/**
* @method clusterTypeFactory
* @description The cluster runtime a set of options selects, as the string every
* command line and volume context spells it.
* @param {object} [options] - Options carrying the cluster flags.
* @param {string} [defaultType] - Type when no flag is set; `kind` everywhere except workflows that never provision one.
* @returns {string} `k3s` | `kubeadm` | `kind`.
* @memberof ServerConfBuilder
*/
const clusterTypeFactory = (options = {}, defaultType = 'kind') =>
options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : defaultType;
/**
* A row returned by {@link UnderpostKubectl.get}. Column names come from
* `kubectl get -o wide`; Services expose their port column as `PORT(S)`.
*
* @typedef {Object<string, string|undefined>} ExposeKubernetesResource
* @property {string} NAME - Kubernetes resource name.
*/
/**
* @method exposeTcpPortsFactory
* @description Extracts TCP Service ports from a parsed
* `kubectl get svc -o wide` row. NodePort suffixes are ignored, so
* `8080:32080/TCP` resolves to Service port `8080`.
* @param {ExposeKubernetesResource} resource - Parsed Kubernetes resource row.
* @returns {number[]} Positive TCP Service ports in the order reported by kubectl.
* @memberof ServerConfBuilder
*/
const exposeTcpPortsFactory = (resource) =>
`${resource?.['PORT(S)'] || ''}`
.split(',')
.filter((port) => port.includes('/TCP'))
.map((port) => parseInt(port.split(':')[0]))
.filter((port) => Number.isInteger(port) && port > 0);
/**
* @method exposePathPartsFactory
* @description Parses a comma-separated expose runner path into safe literal
* Kubernetes name fragments. These are literal fragments, not regular
* expressions or shell input.
* @param {string} [path=''] - Comma-separated Service or Pod name fragments.
* @returns {string[]} Trimmed, non-empty literal resource-name fragments.
* @throws {Error} When no fragment is supplied or a fragment contains
* characters outside `[a-zA-Z0-9._-]`.
* @memberof ServerConfBuilder
*/
const exposePathPartsFactory = (path = '') => {
const parts = `${path}`
.split(',')
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) throw new Error('Expose requires a Service or Pod name in path');
if (parts.some((part) => !/^[a-zA-Z0-9._-]+$/.test(part)))
throw new Error(`Invalid Kubernetes resource name match: ${path}`);
return parts;
};
/**
* @method exposePartialMatchesFactory
* @description Selects every resource whose `NAME` contains any requested
* literal path fragment. Results follow path-fragment order, with an exact name
* before partial names in each group, then lexical name order. The input array
* is not mutated.
* @param {ExposeKubernetesResource[]} resources - Parsed Kubernetes resource rows.
* @param {string[]} pathParts - Literal name fragments from {@link exposePathPartsFactory}.
* @returns {ExposeKubernetesResource[]} Matching resource rows in deterministic order.
* @memberof ServerConfBuilder
*/
const exposePartialMatchesFactory = (resources, pathParts) =>
resources
.filter(({ NAME }) => pathParts.some((part) => `${NAME || ''}`.includes(part)))
.sort((a, b) => {
const pathIndexA = pathParts.findIndex((part) => `${a.NAME || ''}`.includes(part));
const pathIndexB = pathParts.findIndex((part) => `${b.NAME || ''}`.includes(part));
const exactA = a.NAME === pathParts[pathIndexA] ? 0 : 1;
const exactB = b.NAME === pathParts[pathIndexB] ? 0 : 1;
return pathIndexA - pathIndexB || exactA - exactB || `${a.NAME}`.localeCompare(`${b.NAME}`);
});
/**
* @method exposePortListFactory
* @description Parses and validates a comma-separated CLI port list.
* @param {string|number} [value=''] - Comma-separated port values.
* @param {string} [optionName='ports'] - Option name used in validation errors.
* @returns {number[]} Ordered TCP ports, preserving their CLI indices.
* @throws {Error} When an item is empty, non-integer, or outside `1..65535`.
* @memberof ServerConfBuilder
*/
const exposePortListFactory = (value = '', optionName = 'ports') => {
if (value === '' || value === undefined || value === null) return [];
const values = `${value}`.split(',').map((port) => port.trim());
if (values.some((port) => port === '')) throw new Error(`Invalid ${optionName}: ${value}`);
const ports = values.map(Number);
if (ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))
throw new Error(`Invalid ${optionName}: ${value}`);
return ports;
};
/**
* A validated Kubernetes port-forward mapping.
*
* @typedef {Object} ExposePortMapping
* @property {string} kindType - Kubernetes resource kind (`svc` or `pod`).
* @property {string} name - Kubernetes resource name.
* @property {number} localPort - Host-side listening port.
* @property {number} remotePort - Service or container-side destination port.
*/
/**
* @method exposePortPlanFactory
* @description Builds a complete, collision-free port-forward plan. With more
* than one matched resource, container and host port lists map by resource
* index. With one resource, list items map pairwise to multiple ports.
* @param {object} options - Port planning options.
* @param {ExposeKubernetesResource[]} options.resources - Ordered matched resources.
* @param {string} options.kindType - Kubernetes resource kind (`svc` or `pod`).
* @param {number[]} [options.containerPorts=[]] - Explicit destination ports.
* @param {number[]} [options.hostPorts=[]] - Explicit host listening ports.
* @param {function(ExposeKubernetesResource): number[]} [options.portsOf=exposeTcpPortsFactory] - Declared-port resolver.
* @returns {ExposePortMapping[]} Complete port-forward mappings.
* @throws {Error} When list cardinality cannot map by resource/port index, no
* destination port exists, an explicit host port repeats, or an automatic port
* cannot fit inside `1..65535`.
* @memberof ServerConfBuilder
*/
const exposePortPlanFactory = ({
resources,
kindType,
containerPorts = [],
hostPorts = [],
portsOf = exposeTcpPortsFactory,
}) => {
const resourceCount = resources.length;
const multipleResources = resourceCount > 1;
if (multipleResources && containerPorts.length > 0 && containerPorts.length !== resourceCount)
throw new Error(`--expose-container-ports requires ${resourceCount} ports for ${resourceCount} resources`);
if (multipleResources && hostPorts.length > 0 && hostPorts.length !== resourceCount)
throw new Error(`--expose-host-ports requires ${resourceCount} ports for ${resourceCount} resources`);
const portGroups = resources.map((resource, resourceIndex) => {
const declaredPorts = [...new Set(portsOf(resource))];
let remotePorts = [];
if (containerPorts.length > 0) {
remotePorts = multipleResources ? [containerPorts[resourceIndex]] : [...containerPorts];
} else if (hostPorts.length > 0 && !multipleResources) {
remotePorts = hostPorts.map((hp) => (declaredPorts.includes(hp) ? hp : null)).filter(Boolean);
if (remotePorts.length !== hostPorts.length) {
remotePorts = declaredPorts.slice(0, hostPorts.length);
}
} else {
remotePorts = declaredPorts;
}
if (remotePorts.length === 0)
throw new Error(`No declared TCP port for ${kindType}/${resource.NAME}; pass --expose-container-ports <ports>`);
const localPorts = hostPorts.length ? (multipleResources ? [hostPorts[resourceIndex]] : [...hostPorts]) : [];
if (localPorts.length > 0 && localPorts.length !== remotePorts.length)
throw new Error(
`Host/container port counts differ for ${kindType}/${resource.NAME}: ${localPorts.length}/${remotePorts.length}`,
);
return { resource, remotePorts, localPorts };
});
const plan = [];
const usedLocalPorts = new Set();
for (const { resource, remotePorts, localPorts } of portGroups)
for (const [portIndex, remotePort] of remotePorts.entries()) {
const explicitLocalPort = localPorts[portIndex];
let localPort = explicitLocalPort || remotePort;
if (explicitLocalPort && usedLocalPorts.has(localPort))
throw new Error(`Duplicate --expose-host-ports value: ${localPort}`);
while (!explicitLocalPort && usedLocalPorts.has(localPort)) localPort++;
if (localPort > 65535) throw new Error(`No valid host port remains for ${kindType}/${resource.NAME}`);
usedLocalPorts.add(localPort);
plan.push({ kindType, name: resource.NAME, localPort, remotePort });
}
return plan;
};
/**
* @method gatewayApiEnabledFactory
* @description Whether a workflow routes through the Gateway API stack.
*
* On unless explicitly disabled, in every runner: the Gateway API with QUIC/HTTP3
* is the platform's routing stack, and the Contour HTTPProxy set is the fallback
* a caller opts into with `--disable-gateway-api`. `--gateway-api` stays
* meaningful as an explicit request, so a caller that passes it is never
* second-guessed. Reading it from one place is what keeps a runner from
* defaulting to a different stack than the one that deployed the routes.
* @param {object} [options] - Runner/deploy options.
* @returns {boolean} True when the Gateway API stack is in effect.
* @memberof ServerConfBuilder
*/
const gatewayApiEnabledFactory = (options = {}) => options.gatewayApi === true || options.disableGatewayApi !== true;
/**
* @method clusterContextFactory
* @description The inverse of {@link ServerConfBuilder.clusterTypeFactory}: a
* cluster type as the option flags a runner reads.
*
* A workflow resolves its cluster type once and passes it to spawned commands as
* `--${clusterType}`. A runner invoked in-process gets no such string — it sees
* the raw options, where every consumer independently defaults to kind: the
* image pull (`docker exec kind-worker`), the node resolution behind hostPath
* `nodeAffinity`, and the volume cluster context. This carries the choice across
* that boundary.
* @param {string} clusterType - `kubeadm` | `k3s` | `kind`.
* @returns {{kind: boolean, kubeadm: boolean, k3s: boolean}} Mutually exclusive context flags.
* @memberof ServerConfBuilder
*/
const clusterContextFactory = (clusterType) => ({
kind: clusterType === 'kind',
kubeadm: clusterType === 'kubeadm',
k3s: clusterType === 'k3s',
});
/**
* @method waitForPort
* @description Polls a TCP port until it reaches the wanted state.
*
* Single source of truth for every "is it listening yet" wait: a port-forward
* coming up locally, a freshly provisioned node's sshd, and the closed edge that
* proves a reboot actually started. The probe is a native connect rather than a
* shelled-out one, so it needs neither a shell nor `timeout` on the host and
* reports the same result on every platform.
* @param {number} port - Port to probe.
* @param {string} [host] - Host to probe.
* @param {boolean} [open] - Wanted state: true waits for the port to accept, false waits for it to refuse.
* @param {number} [timeoutMs] - Maximum wait window.
* @param {number} [intervalMs] - Delay between attempts.
* @param {number} [connectTimeoutMs] - Per-attempt connect timeout.
* @returns {Promise<boolean>} True once the wanted state is observed, false on timeout.
* @memberof ServerConfBuilder
*/
const waitForPort = async ({
port,
host = '127.0.0.1',
open = true,
timeoutMs = 60 * 1000,
intervalMs = 2000,
connectTimeoutMs = 5000,
}) => {
const probe = () =>
new Promise((resolve) => {
const socket = new net.Socket();
const done = (reachable) => {
socket.destroy();
resolve(reachable);
};
socket.setTimeout(connectTimeoutMs);
socket.once('connect', () => done(true));
socket.once('timeout', () => done(false));
socket.once('error', () => done(false));
socket.connect(port, host);
});
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if ((await probe()) === open) return true;
await timer(intervalMs);
}
logger.warn(`Port ${host}:${port} was not ${open ? 'reachable' : 'closed'} within timeout`, { timeoutMs });
return false;
};
/**
* Creates and writes the /etc/hosts file for a deployment.
* @method etcHostFactory
* @param {Array<string>} hosts - List of hosts to be added to the hosts file.
* @param {object} options - Options for the hosts file creation.
* @param {boolean} options.append - Whether to append to the existing hosts file.
* @param {string} [options.blockId] - Replace an idempotent owned block while preserving unrelated entries.
* @param {string} [options.path=/etc/hosts] - Hosts file path; injectable for tests.
* @returns {{renderHosts: string, changed: boolean}} Rendered content and whether the file changed.
* @memberof ServerConfBuilder
*/
const etcHostFactory = (hosts = [], options = { append: false }) => {
hosts = hosts.map((host) => {
try {
if (!host.startsWith('http')) host = `http://${host}`;
const hostname = new URL(host).hostname;
logger.info('Hostname extract valid', { host, hostname });
return hostname;
} catch (e) {
logger.warn('No hostname extract valid', host);
return host;
}
});
const renderHosts = `127.0.0.1 ${hosts.join(
' ',
)} localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6`;
const hostsPath = options?.path || '/etc/hosts';
if (options?.blockId) {
if (!/^[A-Za-z0-9._-]+$/.test(options.blockId)) throw new Error(`Invalid /etc/hosts block id: ${options.blockId}`);
const beginMarker = `# underpost hosts ${options.blockId}:begin`;
const endMarker = `# underpost hosts ${options.blockId}:end`;
const existing = fs.existsSync(hostsPath) ? fs.readFileSync(hostsPath, 'utf8') : '';
const begin = existing.indexOf(beginMarker);
const end = begin === -1 ? -1 : existing.indexOf(endMarker, begin);
let outsideBlock = existing;
if (begin !== -1)
outsideBlock = `${existing.slice(0, begin)}${end === -1 ? '' : existing.slice(end + endMarker.length)}`;
outsideBlock = outsideBlock.trimEnd();
const updated = `${outsideBlock}${outsideBlock ? '\n' : ''}${beginMarker}\n${renderHosts}\n${endMarker}\n`;
const changed = updated !== existing;
if (changed) fs.writeFileSync(hostsPath, updated, 'utf8');
return { renderHosts, changed };
}
if (options && options.append && fs.existsSync(hostsPath)) {
fs.writeFileSync(
hostsPath,
fs.readFileSync(hostsPath, 'utf8') +
`
${renderHosts}`,
'utf8',
);
} else fs.writeFileSync(hostsPath, renderHosts, 'utf8');
return { renderHosts, changed: true };
};
/**
* Resolves the concrete deploy ids a build or conf-sync run should iterate over.
*
* The meta deploy id `dd` fans out to the comma separated ids declared in
* `engine-private/deploy/dd.router`; when that file is absent (e.g. the private
* repository is not checked out) it falls back to {@link ServerConfBuilder.DEFAULT_DEPLOY_ID}.
* Any other value is parsed as a comma separated list.
* Entries are trimmed and empties dropped.
*
* @method resolveDeployList
* @param {string} deployId - A deploy id, a comma separated list, or the `dd` meta id.
* @returns {string[]} Ordered list of concrete deploy ids.
* @memberof ServerConfBuilder
*/
const resolveDeployList = (deployId) =>
(deployId === 'dd'
? fs.existsSync('./engine-private/deploy/dd.router')
? fs.readFileSync('./engine-private/deploy/dd.router', 'utf8')
: DEFAULT_DEPLOY_ID
: deployId
)
.split(',')
.map((id) => id.trim())
.filter(Boolean);
/**
* Syncs a single deploy id's private configuration into its dedicated
* `engine-<suffix>-private` repository and pushes the result.
*
* Idempotent and safe to rerun: the private repo is cloned when missing or reset to a clean
* checkout when present, then the deploy id's `conf` folder, matching `replica` and
* `itc-scripts` entries, and any caller-supplied `extraPaths` payloads are mirrored. The
* commit/push step is a no-op when nothing changed (`silentOnError`).
*
* @method syncPrivateConf
* @param {string} deployId - A concrete deploy id (e.g. `dd-cyberia`), not the `dd` meta id.
* @param {string[]} [extraPaths=[]] - Extra `./engine-private` payload paths to mirror (from the
* deploy's product catalog), kept out of this module so it stays product-agnostic.
* @returns {void}
* @memberof ServerConfBuilder
*/
const syncPrivateConf = (deployId, extraPaths = []) => {
const suffix = deployId.split('dd-')[1];
const privateRepoName = `engine-${suffix}-private`;
const privateGitUri = `${process.env.GITHUB_USERNAME}/${privateRepoName}`;
const privateRepoPath = `../${privateRepoName}`;
if (!fs.existsSync(privateRepoPath)) {
shellExec(`cd .. && underpost clone ${privateGitUri}`, { silent: true });
} else {
shellExec(`git config --global --add safe.directory '${dir.resolve(privateRepoPath)}'`);
shellExec(`cd ${privateRepoPath} && git checkout . && git clean -f -d && underpost pull . ${privateGitUri}`, {
silent: true,
});
}
const confDest = `${privateRepoPath}/conf/${deployId}`;
fs.removeSync(confDest);
fs.mkdirSync(confDest, { recursive: true });
fs.copySync(`./engine-private/conf/${deployId}`, confDest);
fs.removeSync(`${privateRepoPath}/replica`);
for (const payloadDir of ['replica', 'itc-scripts']) {
const srcDir = `./engine-private/${payloadDir}`;
if (!fs.existsSync(srcDir)) continue;
for (const entry of fs.readdirSync(srcDir))
if (entry.match(deployId)) fs.copySync(`${srcDir}/${entry}`, `${privateRepoPath}/${payloadDir}/${entry}`);
}
for (const extraPath of extraPaths) fs.copySync(`./engine-private/${extraPath}`, `${privateRepoPath}/${extraPath}`);
shellExec(
`cd ${privateRepoPath}` +
` && git add .` +
` && underpost cmt . ci engine-core-conf 'Update ${deployId} conf'` +
` && underpost push . ${privateGitUri}`,
{ silent: true, silentOnError: true },
);
};
/**
* Moves a deploy's public template sources into the engine working tree ahead of
* the build copy step. Idempotent and safe to rerun: each move is guarded by
* `existsSync`, so already-moved or absent sources are skipped rather than throwing.
* The `[src, dest]` pairs come from the deploy's product catalog (passed in), so
* this module stays product-agnostic.
*
* @method syncDeployIdSources
* @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` move pairs.
* @returns {boolean} `true` when any sources were declared, else `false`.
* @memberof ServerConfBuilder
*/
const syncDeployIdSources = (sourceMoves = []) => {
if (!sourceMoves.length) return false;
for (const dir of ['src/api', 'src/client/components', 'src/client/public', 'src/client/services'])
fs.mkdirSync(dir, { recursive: true });
for (const [src, dest] of sourceMoves) if (fs.existsSync(src)) fs.moveSync(src, dest, { overwrite: true });
return true;
};
/**
* Rebuilds the standalone `pwa-microservices-template` from scratch out of the current
* engine source tree.
*
* Clones the template repo next to the engine when missing, otherwise resets it to a clean
* pristine checkout, then syncs every engine-tracked file the template is allowed to carry
* ({@link validateTemplatePath}), strips engine-only + product modules, restores the template's
* own CI workflows + guest services, and rewrites `package.json` / `package-lock.json` / `README`
* so the result is a standalone, installable project. Throws on failure; callers own exit codes.
*
* Product catalogs are read dynamically ({@link module:src/server/catalog} `loadProductCatalogs`),
* so this stays decoupled from — and survives removal of — any product module.
*
* @method buildTemplate
* @param {object} [options]
* @param {string} [options.srcPath='./'] - Engine source root to sync from.
* @param {string} [options.toPath='../pwa-microservices-template'] - Template output path.
* @returns {Promise<void>}
* @memberof ServerConfBuilder
*/
const buildTemplate = async ({ srcPath = './', toPath = '../pwa-microservices-template' } = {}) => {
const walk = (await import('ignore-walk')).default;
const { TEMPLATE_RESTORE_PATHS, TEMPLATE_KEYWORDS, TEMPLATE_DESCRIPTION } =
await import('../projects/underpost/catalog-underpost.js');
const { loadProductCatalogs } = await import('./catalog.js');
const githubUsername = process.env.GITHUB_USERNAME;
logger.info('Build template', { srcPath, toPath });
const sourceFiles = (
await new Promise((resolve) =>
walk({ path: srcPath, ignoreFiles: [`.gitignore`], includeEmpty: false, follow: false }, (...args) =>
resolve(args[1]),
),
)
).filter((p) => !p.startsWith('.git'));
fs.removeSync(`${githubUsername}/pwa-microservices-template`);
shellExec(`cd .. && node engine/bin clone ${githubUsername}/pwa-microservices-template`);
shellExec(`cd ${toPath} && git config core.filemode false`);
for (const copyPath of sourceFiles) {
if (copyPath === 'NaN') continue;
const absolutePath = `${srcPath}/${copyPath}`;
if (!validateTemplatePath(absolutePath)) continue;
const folder = getDirname(`${toPath}/${copyPath}`);
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
logger.info('build', `${toPath}/${copyPath}`);
fs.copyFileSync(absolutePath, `${toPath}/${copyPath}`);
}
fs.copySync(`./.vscode`, `${toPath}/.vscode`);
fs.copySync(`./src/client/public/default`, `${toPath}/src/client/public/default`);
// Preserve the template's own README + package.json identity before merging engine metadata.
for (const checkoutPath of ['README.md', 'package.json']) shellExec(`cd ${toPath} && git checkout ${checkoutPath}`);
// Strip each product catalog's `stripPaths` (aggregated dynamically) plus the engine-only
// workflows, deploy manifests, and product catalog modules.
const productStripPaths = (await loadProductCatalogs()).flatMap((c) => c.stripPaths);
for (const deletePath of productStripPaths) {
const target = `${toPath}/${deletePath}`;
if (fs.existsSync(target)) fs.removeSync(target);
}
shellExec(`rm -rf ${toPath}/.github`);
shellExec(`rm -rf ${toPath}/manifests/deployment/dd-*`);
shellExec(`rm -rf ${toPath}/deploy`);
fs.mkdirSync(`${toPath}/.github/workflows`, { recursive: true });
for (const restorePath of TEMPLATE_RESTORE_PATHS) {
const dest = `${toPath}/${restorePath}`;
if (fs.statSync(restorePath).isDirectory()) fs.copySync(restorePath, dest, { overwrite: true });
else fs.copyFileSync(restorePath, dest);
}
// ── package.json: take engine deps/scripts/version, keep template identity. ──
const originPackageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const templatePackageJson = JSON.parse(fs.readFileSync(`${toPath}/package.json`, 'utf8'));
const templateName = templatePackageJson.name;
templatePackageJson.dependencies = originPackageJson.dependencies;
templatePackageJson.devDependencies = originPackageJson.devDependencies;
templatePackageJson.version = originPackageJson.version;
templatePackageJson.scripts = originPackageJson.scripts;
templatePackageJson.overrides = originPackageJson.overrides;
templatePackageJson.name = templateName;
templatePackageJson.description = TEMPLATE_DESCRIPTION;
templatePackageJson.keywords = TEMPLATE_KEYWORDS;
delete templatePackageJson.scripts['build:template'];
fs.writeFileSync(`${toPath}/package.json`, JSON.stringify(templatePackageJson, null, 4), 'utf8');
// ── package-lock.json: mirror engine packages, keep template name/version on the root entry. ──
const originPackageLockJson = JSON.parse(fs.readFileSync('./package-lock.json', 'utf8'));
const templatePackageLockJson = JSON.parse(fs.readFileSync(`${toPath}/package-lock.json`, 'utf8'));
const originBasePackageLock = newInstance(templatePackageLockJson.packages['']);
templatePackageLockJson.name = templateName;
templatePackageLockJson.version = originPackageLockJson.version;
templatePackageLockJson.packages = originPackageLockJson.packages;
templatePackageLockJson.packages[''].name = templateName;
templatePackageLockJson.packages[''].version = originPackageLockJson.version;
templatePackageLockJson.packages[''].hasInstallScript = originBasePackageLock.hasInstallScript;
templatePackageLockJson.packages[''].license = originBasePackageLock.license;
fs.writeFileSync(`${toPath}/package-lock.json`, JSON.stringify(templatePackageLockJson, null, 4), 'utf8');
fs.writeFileSync(
`${toPath}/README.md`,
fs
.readFileSync('./README.md', 'utf8')
.replace('<!-- template-title -->', '#### Base template for pwa/api-rest projects.'),
'utf8',
);
};
const updatePrivateTemplateRepo = async () => {
const templatePath = '/home/dd/pwa-microservices-template';
shellExec(`sudo rm -rf ${templatePath}
cd /home/dd/engine && npm run build:template
cd /home/dd
underpost clone --bare underpostnet/pwa-microservices-template-private
sudo rm -rf ${templatePath}/.git
mv ./pwa-microservices-template-private.git ${templatePath}/.git
cd ${templatePath}
npm install --omit=dev --ignore-scripts
git init
git config user.name 'underpostnet'
git config user.email 'development@underpost.net'
git add .`);
const hasChanges = shellExec(`node bin cmt ${templatePath} --has-changes`, {
stdout: true,
silent: true,
disableLog: true,
}).trim();
if (hasChanges === '1') {
shellExec(
`cd ${templatePath} && git commit -m 'Update template' && underpost push . underpostnet/pwa-microservices-template-private`,
);
}
};
/**
* @method updatePrivateEngineTestRepo
* @description Publishes a deploy id's freshly assembled template to its private
* **test** source repo `engine-test-<idPart>` (separate from the production
* `engine-<idPart>`). A pod started with `underpost start --build --private-test-repo`
* clones this repo, so work-in-progress engine source can be tested end to end
* without touching the production source. Mirrors {@link updatePrivateTemplateRepo}
* but per-deploy-id and against the test repo.
*
* Assumes the deploy id template has already been assembled at the template path
* (run `node bin/build <deployId>` first, or use `node bin/build <deployId> --update-private`).
* @param {string} deployId - Concrete deploy id (e.g. `dd-core`).
* @returns {Promise<void>}
* @memberof ServerConfBuilder
*/
const updatePrivateEngineTestRepo = async (deployId) => {
const username = process.env.GITHUB_USERNAME || 'underpostnet';
const repoName = `engine-test-${deployId.split('-')[1]}`;
const templatePath = '/home/dd/pwa-microservices-template';
if (!fs.existsSync(templatePath))
throw new Error(`updatePrivateEngineTestRepo: assemble the template first (node bin/build ${deployId})`);
// Detach the assembled working tree from any engine-build git history.
shellExec(`sudo rm -rf ${templatePath}/.git`);
// Adopt the test repo's existing history when present (so the push is a delta);
// otherwise publish a fresh history on first push.
shellExec(`cd /home/dd && sudo rm -rf ./${repoName}.git && underpost clone --bare ${username}/${repoName}`, {
silent: true,
disableLog: true,
silentOnError: true,
});
if (fs.existsSync(`/home/dd/${repoName}.git`)) shellExec(`mv /home/dd/${repoName}.git ${templatePath}/.git`);
// `git init` converts the moved bare repo into a normal work-tree repo (bare
// clones have no work tree, so `git add` would fail), and bootstraps a fresh
// repo on first publish. Idempotent — mirrors updatePrivateTemplateRepo.
shellExec(`cd ${templatePath}
git init
git config user.name '${username}'
git config user.email 'development@underpost.net'
git add .`);
const hasChanges = shellExec(`node bin cmt ${templatePath} --has-changes`, {
stdout: true,
silent: true,
disableLog: true,
}).trim();
if (hasChanges === '1')
shellExec(`cd ${templatePath} && git commit -m 'Update ${repoName}' && underpost push . ${username}/${repoName}`);
else logger.info('No changes to publish', { repoName });
};
/**
* @function clusterInstancesFactory
* @description Binds the instance ids requested by the `cluster` runner to the
* deploys that actually declare them.
*
* An instance belongs to a deploy through that deploy's own
* `conf.instances.json` — nothing else relates the two — so the same id under a
* different deploy is a different workload, and an id no deploy declares is a
* typo rather than a silent no-op. A deploy without the file simply has no
* instances.
*
* Ids are returned as requested, not expanded: `run instance` owns variant
* expansion, so a template id (`mmo-server`) is handed over whole and deploys
* its whole family. Hosts *are* expanded, because they are needed before any
* instance runs — `/etc/hosts` is written in one pass for every host the
* gateway will serve.
* @param {Array<string>} deployList - Deploy ids being brought up.
* @param {string} [instanceList] - `+`-separated instance/template ids.
* @returns {{ byDeployId: Object<string,{ids: Array<string>, hosts: Array<string>}>, unmatched: Array<string> }}
* Per-deploy selection, and the requested ids no deploy declares.
*/
const clusterInstancesFactory = (deployList = [], instanceList = '') => {
const requested = `${instanceList || ''}`.split('+').filter((id) => id.trim());
const byDeployId = Object.fromEntries(
deployList.map((deployId) => {
const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
if (requested.length === 0 || !fs.existsSync(confPath)) return [deployId, { ids: [], hosts: [] }];
const confInstances = loadConfInstances(deployId);
const matched = requested
.map((id) => ({ id, instances: selectConfInstances(confInstances, id) }))
.filter((entry) => entry.instances.length > 0);
return [
deployId,
{
ids: matched.map((entry) => entry.id),
hosts: [...new Set(matched.flatMap((entry) => entry.instances.map((instance) => instance.host)))],
},
];
}),
);
return {
byDeployId,
unmatched: requested.filter((id) => !deployList.some((deployId) => byDeployId[deployId].ids.includes(id))),
};
};
export {
Config,
loadConf,
loadConfInstances,
normalizeInstanceTopology,
dispatchBuildInstanceEnv,
loadProjectInstanceEnvBuilder,
loadInstanceTopology,
readConfInstances,
selectConfInstances,
loadReplicas,
cloneConf,
getCapVariableName,
addClientConf,
buildClientSrc,
buildApiSrc,
addApiConf,
addWsConf,
buildWsSrc,
cloneSrcComponents,
buildProxyRouter,
getDataDeploy,
validateTemplatePath,
buildReplicaId,
mergeFile,
getPathsSSR,
buildKindPorts,
buildPortProxyRouter,
splitFileFactory,
generateSecurePassword,
resolveReplicaCount,
pathPortAssignmentFactory,
deployRangePortFactory,
awaitDeployMonitor,
buildCliDoc,
getInstanceContext,
buildApiConf,
buildClientStaticConf,
isDeployRunnerContext,
isDevProxyContext,
devProxyHostFactory,
isTlsDevProxy,
getTlsHosts,
resolveHostKeyContext,
resolveConfSecrets,
loadConfServerJson,
getConfFolder,
getConfFilePath,
readConfJson,
DEFAULT_DEPLOY_ID,
clusterContextFactory,
clusterTypeFactory,
exposeTcpPortsFactory,
exposePathPartsFactory,
exposePartialMatchesFactory,
exposePortListFactory,
exposePortPlanFactory,
deployHostsFactory,
clusterInstancesFactory,
etcHostFactory,
gatewayApiEnabledFactory,
instanceHttpRouteRulesFactory,
instanceInterceptStatusesFactory,
instancePortFactory,
instanceProjectPathFactory,
instanceProxyRoutesFactory,
instanceStatusPageDeployIdFactory,
instanceStatusPageEntriesFactory,
deployTrafficEntriesFactory,
trafficProbePathsFactory,
hostIngressFactsFactory,
curlStatusChainFactory,
hostRenderInstancesFactory,
instanceTrafficPlanFactory,
isTrafficServingFactory,
nextTrafficFactory,
schedulableNodeFactory,
stopPlanFactory,
trafficFromRoutingInfoFactory,
trafficTableRowsFactory,
resolveEnvScoped,
sortInstancesByPath,
waitForPort,
resolveDeployList,
syncPrivateConf,
syncDeployIdSources,
buildTemplate,
updatePrivateTemplateRepo,
updatePrivateEngineTestRepo,
};