@underpostnet/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
1,075 lines (1,048 loc) • 66.6 kB
JavaScript
/**
* Deploy module for managing the deployment of applications and services.
* @module src/cli/deploy.js
* @namespace UnderpostDeploy
*/
import {
buildKindPorts,
buildPortProxyRouter,
buildProxyRouter,
Config,
cronDeployIdResolve,
deployRangePortFactory,
getDataDeploy,
loadConfServerJson,
loadReplicas,
pathPortAssignmentFactory,
} from '../server/conf.js';
import { loggerFactory } from '../server/logger.js';
import { shellExec } from '../server/process.js';
import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-status.js';
import fs from 'fs-extra';
import dotenv from 'dotenv';
import os from 'node:os';
import Underpost from '../index.js';
const logger = loggerFactory(import.meta);
/**
* @class UnderpostDeploy
* @description Manages the deployment of applications and services.
* This class provides a set of static methods to handle the deployment process,
* including resource allocation, configuration management, and Kubernetes deployment.
* @memberof UnderpostDeploy
*/
class UnderpostDeploy {
static API = {
/**
* Creates a router configuration for a list of deployments.
* @param {string} deployList - List of deployment IDs to include in the router.
* @param {string} env - Environment for which the router is being created.
* @returns {object} - Router configuration for the specified deployments.
* @memberof UnderpostDeploy
*/
async routerFactory(deployList, env) {
const initEnvPath = `./engine-private/conf/${deployList.split(',')[0]}/.env.${env}`;
const initEnvObj = dotenv.parse(fs.readFileSync(initEnvPath, 'utf8'));
process.env.PORT = initEnvObj.PORT;
process.env.NODE_ENV = env;
await Config.build('proxy', deployList);
return buildPortProxyRouter({ port: env === 'development' ? 80 : 443, proxyRouter: buildProxyRouter() });
},
/**
* Creates a YAML service configuration for a deployment.
* @param {string} deployId - Deployment ID for which the service is being created.
* @param {string} path - Path for which the service is being created.
* @param {string} env - Environment for which the service is being created.
* @param {number} port - Port number for the service.
* @param {Array<string>} deploymentVersions - List of deployment versions.
* @param {string} serviceId - Custom service name (optional).
* @param {Array} pathRewritePolicy - Path rewrite policy (optional).
* @param {object} timeoutPolicy - Timeout policy (optional).
* @param {object} retryPolicy - Retry policy (optional).
* @returns {string} - YAML service configuration for the specified deployment.
* @memberof UnderpostDeploy
*/
deploymentYamlServiceFactory({
deployId,
path,
env,
port,
deploymentVersions,
serviceId,
pathRewritePolicy,
timeoutPolicy,
retryPolicy,
}) {
return `
- conditions:
- prefix: ${path}
${
pathRewritePolicy
? `pathRewritePolicy:
replacePrefix:
${pathRewritePolicy.map(
(rd) => `- prefix: ${rd.prefix}
replacement: ${rd.replacement}
`,
).join(`
`)}`
: ''
}${
timeoutPolicy
? `\n timeoutPolicy:\n${timeoutPolicy.response ? ` response: ${timeoutPolicy.response}\n` : ''}${
timeoutPolicy.idle ? ` idle: ${timeoutPolicy.idle}\n` : ''
}`
: ''
}${
retryPolicy
? `\n retryPolicy:\n${retryPolicy.count !== undefined ? ` count: ${retryPolicy.count}\n` : ''}${
retryPolicy.perTryTimeout ? ` perTryTimeout: ${retryPolicy.perTryTimeout}\n` : ''
}`
: ''
}
enableWebsockets: true
services:
${deploymentVersions
.map(
(version, i) =>
` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
port: ${port}
weight: ${i === 0 ? 100 : 0}
`,
)
.join('')}`;
},
/**
* Builds Kubernetes probes that gate on the in-pod internal status endpoint.
*
* HTTP mode (default) aligns Kubernetes pod readiness with actual Underpost
* runtime readiness:
* - readinessProbe → GET /_internal/ready (200 only when running-deployment)
* - livenessProbe → GET /_internal/health (deadlock / hung-process detection)
* - startupProbe → GET /_internal/ready (long window for hot-built/slow boots)
*
* Migration: pass `useHttp: false` to emit the legacy TCP socket probes
* (port-bound only) for deployments not yet serving the internal endpoint.
*
* @param {object} opts
* @param {number} opts.port - In-pod internal status port (deployment base PORT).
* @param {boolean} [opts.useHttp=true] - Emit HTTP probes; false → legacy TCP.
* @param {boolean} [opts.liveness=true] - Include a livenessProbe.
* @param {boolean} [opts.startup=true] - Include a startupProbe.
* @returns {{readinessProbe: object, livenessProbe?: object, startupProbe?: object}}
* @memberof UnderpostDeploy
*/
runtimeProbesFactory({ port, useHttp = true, liveness = true, startup = true } = {}) {
if (!port) return {};
if (!useHttp) {
const tcp = { tcpSocket: { port }, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 6 };
const probes = { readinessProbe: tcp };
if (liveness) probes.livenessProbe = { ...tcp, initialDelaySeconds: 30 };
return probes;
}
const probes = {
readinessProbe: {
httpGet: { path: INTERNAL_READY_PATH, port },
initialDelaySeconds: 5,
periodSeconds: 5,
timeoutSeconds: 3,
failureThreshold: 3,
},
};
if (liveness)
probes.livenessProbe = {
httpGet: { path: INTERNAL_HEALTH_PATH, port },
initialDelaySeconds: 30,
periodSeconds: 15,
timeoutSeconds: 3,
failureThreshold: 3,
};
if (startup)
// A startupProbe suspends readiness/liveness until it first succeeds, so
// its window bounds in-container hot builds and slow boots. 180 × 10s =
// 30 min before the pod is considered failed to start.
probes.startupProbe = {
httpGet: { path: INTERNAL_READY_PATH, port },
initialDelaySeconds: 10,
periodSeconds: 10,
timeoutSeconds: 3,
failureThreshold: 180,
};
return probes;
},
/**
* Creates a YAML deployment configuration for a deployment.
* @param {string} deployId - Deployment ID for which the deployment is being created.
* @param {string} env - Environment for which the deployment is being created.
* @param {string} suffix - Suffix for the deployment.
* @param {object} resources - Resource configuration for the deployment.
* @param {number} replicas - Number of replicas for the deployment.
* @param {string} image - Docker image for the deployment.
* @param {string} namespace - Kubernetes namespace for the deployment.
* @param {Array<object>} volumes - Volume configurations for the deployment.
* @param {Array<string>} cmd - Command to run in the deployment container.
* @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
* @param {boolean} pullBundle - Whether to pull the pre-built client bundle from Cloudinary before starting. Use together with skipFullBuild to skip the local build entirely.
* @param {string} [imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`). When omitted, defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {object} lifecycle - Kubernetes lifecycle hooks configuration for the deployment container.
* @param {object} readinessProbe - Kubernetes readiness probe configuration for the deployment container.
* @param {object} livenessProbe - Kubernetes liveness probe configuration for the deployment container.
* @param {object} startupProbe - Kubernetes startup probe configuration for the deployment container.
* @param {number} containerPort - Container port to expose for the deployment.
* @returns {string} - YAML deployment configuration for the specified deployment.
* @memberof UnderpostDeploy
*/
deploymentYamlPartsFactory({
deployId,
env,
suffix,
resources,
replicas,
image,
namespace,
volumes,
cmd,
skipFullBuild,
pullBundle,
imagePullPolicy,
// K8S lifecycle + probe wiring. Pass-through structures shaped like the
// upstream Kubernetes API, spliced verbatim into the container spec.
// lifecycle: { postStart: { exec: { command: [...] } }, preStop: { exec: { command: [...] } } }
// readinessProbe: { tcpSocket: { port: 8081 }, ... }
// livenessProbe: { tcpSocket: { port: 8081 }, ... }
// containerPort: integer; rendered as ports[0].containerPort. Optional.
lifecycle,
readinessProbe,
livenessProbe,
startupProbe,
containerPort,
// Explicit, secret-free internal status port injected as an env var so the
// in-pod endpoint binds exactly what the probes and the monitor target,
// independent of the ambient `PORT` baked into the image/secret.
internalStatusPort,
}) {
if (!cmd)
cmd =
pullBundle || skipFullBuild
? [
// When pullBundle (or skipFullBuild) is set the container pulls the pre-built client
// bundle from Cloudinary (push-bundle must have been run on the dev machine beforehand).
`underpost secret underpost --create-from-env`,
`underpost start --build --run --pull-bundle --skip-full-build ${deployId} ${env}`,
]
: [
// `npm install -g npm@11.2.0`,
// `npm install -g underpost`,
`underpost secret underpost --create-from-env`,
`underpost start --build --run ${deployId} ${env}`,
];
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
if (!volumes) volumes = [];
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
volumes = volumes.concat(confVolume);
// const containerImage = image ? image : `localhost/rockylinux9-underpost:v${packageJson.version}`;
const containerImage = image ? image : `underpost/underpost-engine:v${packageJson.version}`;
return `apiVersion: apps/v1
kind: Deployment
metadata:
name: ${deployId}-${env}-${suffix}
namespace: ${namespace ? namespace : 'default'}
labels:
app: ${deployId}-${env}-${suffix}
deploy-id: ${deployId}-${env}
spec:
replicas: ${replicas}
selector:
matchLabels:
app: ${deployId}-${env}-${suffix}
template:
metadata:
labels:
app: ${deployId}-${env}-${suffix}
deploy-id: ${deployId}-${env}
spec:
containers:
- name: ${deployId}-${env}-${suffix}
image: ${containerImage}
imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
envFrom:
- secretRef:
name: underpost-config
${
internalStatusPort
? ` env:
- name: UNDERPOST_INTERNAL_PORT
value: "${internalStatusPort}"
`
: ''
}${
containerPort
? ` ports:
- containerPort: ${containerPort}
`
: ''
}${
resources
? ` resources:
requests:
memory: "${resources.requests.memory}"
cpu: "${resources.requests.cpu}"
limits:
memory: "${resources.limits.memory}"
cpu: "${resources.limits.cpu}"`
: ''
}
command:
- /bin/sh
- -c
- >
${cmd.join(' &&\n ')}
${
readinessProbe
? ` readinessProbe:
${JSON.stringify(readinessProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
livenessProbe
? ` livenessProbe:
${JSON.stringify(livenessProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
startupProbe
? ` startupProbe:
${JSON.stringify(startupProbe, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}${
lifecycle
? ` lifecycle:
${JSON.stringify(lifecycle, null, 2)
.split('\n')
.map((l) => ' ' + l)
.join('\n')}
`
: ''
}
${
volumes.length > 0
? Underpost.deploy
.volumeFactory(volumes.map((v) => ((v.version = `${deployId}-${env}-${suffix}`), v)))
.render.split(`\n`)
.map((l) => ' ' + l)
.join(`\n`)
: ''
}
---
apiVersion: v1
kind: Service
metadata:
name: ${deployId}-${env}-${suffix}-service
namespace: ${namespace}
spec:
selector:
app: ${deployId}-${env}-${suffix}
ports:
{{ports}} type: LoadBalancer`;
},
/**
* Builds a manifest for a list of deployments.
* @param {string} deployList - List of deployment IDs to include in the manifest.
* @param {string} env - Environment for which the manifest is being built.
* @param {object} options - Options for the manifest build process.
* @param {string} options.replicas - Number of replicas for each deployment.
* @param {string} options.image - Docker image for the deployment.
* @param {string} options.namespace - Kubernetes namespace for the deployment (defaults to "default").
* @param {string} [options.versions] - Comma-separated list of versions to deploy.
* @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
* @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
* @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
* @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy route generation.
* @param {string} [options.traffic] - Comma-separated active traffic colour(s) used to select which versions receive traffic (e.g. "blue", "green").
* @param {boolean} [options.cert] - Whether to include cert-manager Certificate resources in secret.yaml (production only).
* @param {boolean} [options.selfSigned] - Whether to include TLS block in HTTPProxy using a pre-created self-signed secret. Enables HTTPS for development without cert-manager.
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory.
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
* @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
* @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
* @param {boolean} [options.kubeadm] - Kubeadm cluster context; affects the cluster-type node default when no explicit node is set.
* @param {boolean} [options.k3s] - K3s cluster context; affects the cluster-type node default when no explicit node is set.
* @returns {Promise<void>} - Promise that resolves when the manifest is built.
* @memberof UnderpostDeploy
*/
async buildManifest(deployList, env, options) {
const replicas = options.replicas;
const image = options.image;
if (!options.namespace) options.namespace = 'default';
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
if (!deployId) continue;
const confServer = loadReplicas(
deployId,
loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`),
);
const router = await Underpost.deploy.routerFactory(deployId, env);
const pathPortAssignmentData = await pathPortAssignmentFactory(deployId, router, confServer);
const { fromPort, toPort } = deployRangePortFactory(router);
const deploymentVersions = options.versions.split(',');
fs.mkdirSync(`./engine-private/conf/${deployId}/build/${env}`, { recursive: true });
if (env === 'development') fs.mkdirSync(`./manifests/deployment/${deployId}-${env}`, { recursive: true });
logger.info('port range', { deployId, fromPort, toPort });
// The internal status endpoint binds `fromPort - 1`: app instances bind
// the router range starting at `fromPort`, so this slot is always free
// inside the pod. It is injected into the pod env (UNDERPOST_INTERNAL_PORT)
// and used for both the probes and the monitor's port-forward target so
// all three agree regardless of the image's ambient PORT.
// Opt out with `--disable-runtime-probes` to keep legacy probe-less pods.
const internalPort = fromPort - 1;
const probes = options.disableRuntimeProbes
? {}
: Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
let deploymentYamlParts = '';
for (const deploymentVersion of deploymentVersions) {
deploymentYamlParts += `---
${Underpost.deploy
.deploymentYamlPartsFactory({
deployId,
env,
suffix: deploymentVersion,
replicas,
image,
namespace: options.namespace,
cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
skipFullBuild: options.skipFullBuild,
pullBundle: options.pullBundle,
imagePullPolicy: options.imagePullPolicy,
internalStatusPort: options.disableRuntimeProbes ? undefined : internalPort,
readinessProbe: probes.readinessProbe,
livenessProbe: probes.livenessProbe,
startupProbe: probes.startupProbe,
})
.replace('{{ports}}', buildKindPorts(fromPort, toPort))}
`;
}
fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
Underpost.deploy.buildGrpcServiceManifest({
deployId,
env,
confServer,
namespace: options.namespace,
traffic: options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',') : ['blue'],
});
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
if (confVolume.length > 0) {
// Mirror deployVolume's data-node resolution so the generated manifest
// pins the PV to the same node that physically receives the volume data.
const pvDataNode = Underpost.deploy.resolveDeployNode({
node: options.node,
kind: options.kind,
kubeadm: options.kubeadm,
k3s: options.k3s,
env,
});
let volumeYaml = '';
for (const deploymentVersion of deploymentVersions) {
for (const volume of confVolume) {
if (!volume.claimName) continue;
const pvcId = `${volume.claimName}-${deployId}-${env}-${deploymentVersion}`;
const pvId = pvcId.replace(/^pvc-/, 'pv-');
const hostPath = `/home/dd/engine/volume/${pvId}`;
volumeYaml += `---\n${Underpost.deploy.persistentVolumeFactory({
pvcId,
namespace: options.namespace,
hostPath,
nodeName: pvDataNode,
})}\n`;
}
}
fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/pv-pvc.yaml`, volumeYaml, 'utf8');
}
let proxyYaml = '';
let secretYaml = '';
const customServices = fs.existsSync(`./engine-private/conf/${deployId}/conf.services.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.services.json`))
: [];
for (const host of Object.keys(confServer)) {
if (env === 'production' && options.cert === true)
secretYaml += Underpost.deploy.buildCertManagerCertificate({ host, namespace: options.namespace });
const pathPortAssignment = pathPortAssignmentData[host];
// logger.info('', { host, pathPortAssignment });
let _proxyYaml = Underpost.deploy.baseProxyYamlFactory({ host, env, options });
const deploymentVersions =
options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',') : ['blue'];
let proxyRoutes = '';
const globalTimeoutPolicy =
(options.timeoutResponse && options.timeoutResponse !== '') ||
(options.timeoutIdle && options.timeoutIdle !== '')
? {
response: options.timeoutResponse,
idle: options.timeoutIdle,
}
: undefined;
const globalRetryPolicy =
options.retryCount ||
options.retryCount === 0 ||
(options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
? {
count: options.retryCount,
perTryTimeout: options.retryPerTryTimeout,
}
: undefined;
if (!options.disableDeploymentProxy)
for (const conditionObj of pathPortAssignment) {
const { path, port } = conditionObj;
proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
path,
deployId,
env,
port,
deploymentVersions,
timeoutPolicy: globalTimeoutPolicy,
retryPolicy: globalRetryPolicy,
});
}
for (const customService of customServices) {
const {
path: _path,
port,
serviceId,
host: _host,
pathRewritePolicy,
timeoutPolicy: _timeoutPolicy,
retryPolicy: _retryPolicy,
} = customService;
if (host === _host) {
proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
path: _path,
port,
serviceId,
deploymentVersions,
pathRewritePolicy,
timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
});
}
}
if (proxyRoutes) proxyYaml += _proxyYaml + proxyRoutes;
}
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/proxy.yaml`;
fs.writeFileSync(yamlPath, proxyYaml, 'utf8');
if (env === 'production') {
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
fs.writeFileSync(yamlPath, secretYaml, 'utf8');
} else {
const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
for (const file of deploymentsFiles) {
if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
fs.copyFileSync(
`./engine-private/conf/${deployId}/build/${env}/${file}`,
`./manifests/deployment/${deployId}-${env}/${file}`,
);
}
}
}
}
},
/**
* Builds and writes a gRPC ClusterIP service YAML for a deployment.
* Scans conf.server.json for gRPC ports and emits grpc-service.yaml under
* `engine-private/conf/<deployId>/build/<env>/`. The selector always uses the
* explicit `app: <deployId>-<env>-<traffic>` label to target only the active
* colour (blue or green).
* @param {string} deployId - Deployment ID.
* @param {string} env - Environment ('development' or 'production').
* @param {object} confServer - Parsed conf.server.json content.
* @param {string} [namespace='default'] - Kubernetes namespace.
* @param {string[]} [traffic=['blue']] - Active traffic colour(s) ('blue', 'green', or both).
* @param {string|null} [host=null] - Specific host to scan for gRPC ports. If null, all hosts are scanned.
* @returns {string|null} - Path to the written YAML file, or null if no gRPC ports found.
* @memberof UnderpostDeploy
*/
buildGrpcServiceManifest({ deployId, env, confServer, namespace = 'default', traffic = ['blue'], host = null }) {
const grpcPorts = new Set();
const hostsToScan = host ? [host] : Object.keys(confServer);
for (const h of hostsToScan) {
if (!confServer[h]) continue;
for (const path of Object.keys(confServer[h])) {
const grpc = confServer[h][path].grpc;
if (grpc && grpc.port) grpcPorts.add(parseInt(grpc.port));
}
}
if (grpcPorts.size === 0) return null;
const grpcPortsList = [...grpcPorts]
.map(
(port) => ` - name: grpc-${port}
protocol: TCP
port: ${port}
targetPort: ${port}`,
)
.join('\n');
let grpcServiceYaml = '';
for (const color of traffic) {
const grpcServiceName = `${deployId}-grpc-service-${env}-${color}`;
const selectorYaml = `app: ${deployId}-${env}-${color}`;
grpcServiceYaml += `---
apiVersion: v1
kind: Service
metadata:
name: ${grpcServiceName}
namespace: ${namespace}
labels:
app: ${grpcServiceName}
spec:
type: ClusterIP
selector:
${selectorYaml}
ports:
${grpcPortsList}
`;
logger.info(
`gRPC ClusterIP service YAML written: ${grpcServiceName} (selector: ${selectorYaml}, ports: ${[...grpcPorts].join(', ')})`,
);
}
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
fs.writeFileSync(yamlPath, grpcServiceYaml, 'utf8');
return yamlPath;
},
/**
* Builds a Certificate resource for a host using cert-manager.
* @param {string} host - Hostname for which the certificate is being built.
* @param {string} namespace - Kubernetes namespace for the certificate.
* @returns {string} - Certificate resource YAML for the specified host.
* @memberof UnderpostDeploy
*/
buildCertManagerCertificate({ host, namespace }) {
return `
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ${host}
namespace: ${namespace}
spec:
commonName: ${host}
dnsNames:
- ${host}
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
secretName: ${host}`;
},
/**
* Retrieves the current traffic status for a deployment.
* @param {string} deployId - Deployment ID for which the traffic status is being retrieved.
* @param {object} options - Options for the traffic retrieval.
* @param {string} options.hostTest - Hostname to test for traffic status.
* @param {string} options.namespace - Kubernetes namespace for the deployment.
* @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
* @memberof UnderpostDeploy
*/
getCurrentTraffic(deployId, options = { hostTest: '', namespace: '' }) {
if (!options.namespace) options.namespace = 'default';
// kubectl get deploy,sts,svc,configmap,secret -n default -o yaml --export > default.yaml
const hostTest = options?.hostTest
? options.hostTest
: Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
// Missing HTTPProxy is the canonical "no traffic colour set yet" state
// for blue/green rollouts. silentOnError swallows kubectl's NotFound
// exit so the function can return null cleanly.
const info = shellExec(`sudo kubectl get HTTPProxy/${hostTest} -n ${options.namespace} -o yaml`, {
silent: true,
stdout: true,
silentOnError: true,
});
if (!info) return null;
return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
},
/**
* Creates a base YAML configuration for an HTTPProxy resource.
* @param {string} host - Hostname for which the HTTPProxy is being created.
* @param {string} env - Environment for which the HTTPProxy is being created.
* @param {object} options - Options for the HTTPProxy creation.
* @param {string} options.namespace - Kubernetes namespace for the HTTPProxy.
* @returns {string} - Base YAML configuration for the HTTPProxy resource.
* @memberof UnderpostDeploy
*/
baseProxyYamlFactory({ host, env, options }) {
const includeTls = env !== 'development' || options.selfSigned === true;
return `
---
apiVersion: projectcontour.io/v1
kind: HTTPProxy
metadata:
name: ${host}
namespace: ${options.namespace}
spec:
virtualhost:
fqdn: ${host}${
includeTls
? `
tls:
secretName: ${host}`
: ''
}
routes:`;
},
/**
* Callback function for handling deployment options.
* @param {string} deployList - List of deployment IDs to process.
* @param {string} env - Environment for which the deployment is being processed.
* @param {object} options - Options for the deployment process.
* @param {boolean} options.remove - Whether to remove the deployment.
* @param {boolean} options.infoRouter - Whether to display router information.
* @param {boolean} options.sync - Whether to synchronize deployment configurations.
* @param {boolean} options.buildManifest - Whether to build the deployment manifest.
* @param {boolean} options.infoUtil - Whether to display utility information.
* @param {boolean} options.expose - Whether to expose the deployment.
* @param {boolean} options.cert - Whether to create cert-manager Certificate resources for the deployment.
* @param {string} options.certHosts - Comma-separated list of hosts for which to create cert-manager certificates.
* @param {boolean} options.selfSigned - Use a pre-created self-signed TLS secret instead of cert-manager. The secret must already exist in the namespace with the same name as the host. Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.
* @param {string} options.versions - Comma-separated list of versions to deploy.
* @param {string} options.image - Docker image for the deployment.
* @param {string} options.traffic - Traffic status for the deployment.
* @param {string} options.replicas - Number of replicas for the deployment.
* @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
* @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
* @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
* @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
* @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
* @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
* @param {boolean} options.status - Whether to display deployment status.
* @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
* @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
* @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
* @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
* @param {string} [options.kindType] - Kubernetes resource kind to target when using --expose (defaults to "svc").
* @param {number} [options.port] - Port number override for exposing the deployment.
* @param {string} [options.cmd] - Custom initialization command (comma-separated) for deploymentYamlPartsFactory.
* @param {number} [options.exposePort] - Remote port override when --expose is active (overrides auto-detected service port). Used as both local and remote port unless exposeLocalPort is also set.
* @param {number} [options.exposeLocalPort] - Local port override for --expose (e.g. 80); remote port is still auto-detected. Enables /etc/hosts access without a port in the browser URL.
* @param {boolean} [options.localProxy] - When true (with --expose), forward all service TCP ports locally and start the Node.js path-routing proxy for full path-based routing (e.g. /wp alongside /).
* @param {boolean} [options.tls] - When true (with --expose --local-proxy), start the proxy on port 443 with TLS using self-signed certificates resolved from the local SSL store.
* @param {boolean} [options.k3s] - Whether to use k3s cluster context.
* @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
* @param {boolean} [options.kind] - Whether to use kind cluster context.
* @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); passed through to buildManifest/deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
* @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes.
* @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
* @memberof UnderpostDeploy
*/
async callback(
deployList = '',
env = 'development',
options = {
remove: false,
infoRouter: false,
sync: false,
buildManifest: false,
infoUtil: false,
expose: false,
cert: false,
certHosts: '',
versions: '',
image: '',
traffic: '',
replicas: '',
node: '',
disableUpdateDeployment: false,
disableUpdateProxy: false,
disableDeploymentProxy: false,
disableUpdateVolume: false,
status: false,
disableUpdateUnderpostConfig: false,
namespace: '',
timeoutResponse: '',
timeoutIdle: '',
retryCount: '',
retryPerTryTimeout: '',
kindType: '',
port: 0,
exposePort: 0,
exposeLocalPort: 0,
localProxy: false,
tls: false,
selfSigned: false,
cmd: '',
k3s: false,
kubeadm: false,
kind: false,
gitClean: false,
imagePullPolicy: '',
},
) {
const namespace = options.namespace ? options.namespace : 'default';
if (!deployList && options.certHosts) {
for (const host of options.certHosts.split(',')) {
shellExec(`sudo kubectl apply -f - -n ${namespace} <<EOF
${Underpost.deploy.buildCertManagerCertificate({ host, namespace })}
EOF`);
}
return;
} else if (!deployList) deployList = 'dd-default';
if (deployList === 'dd' && fs.existsSync(`./engine-private/deploy/dd.router`))
deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8');
if (options.status === true) {
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
const instances = [];
if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`)) {
const confInstances = JSON.parse(
fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
);
for (const instance of confInstances) {
const _deployId = `${deployId}-${instance.id}`;
instances.push({
id: instance.id,
host: instance.host,
path: instance.path,
fromPort: instance.fromPort,
toPort: instance.toPort,
fromDebugPort: instance.fromDebugPort,
toDebugPort: instance.toDebugPort,
traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host }),
});
}
}
logger.info('', {
deployId,
env,
traffic: Underpost.deploy.getCurrentTraffic(deployId, { namespace }),
router: await Underpost.deploy.routerFactory(deployId, env),
pods: await Underpost.kubectl.get(deployId),
instances,
});
}
const interfaceName = Underpost.dns.getDefaultNetworkInterface();
logger.info('Machine', {
hostname: os.hostname(),
arch: Underpost.baremetal.getHostArch(),
ipv4Public: await Underpost.dns.getPublicIp(),
ipv4Local: Underpost.dns.getLocalIPv4Address(),
resources: Underpost.cluster.getResourcesCapacity(options.node),
defaultInterfaceName: interfaceName,
defaultInterfaceInfo: os.networkInterfaces()[interfaceName],
});
return;
}
if (!(options.versions && typeof options.versions === 'string')) options.versions = 'blue,green';
if (!options.replicas) options.replicas = 1;
if (options.sync)
await getDataDeploy({
buildSingleReplica: true,
});
if (options.buildManifest === true) await Underpost.deploy.buildManifest(deployList, env, options);
if (options.infoRouter === true || options.buildManifest === true) {
logger.info('router', await Underpost.deploy.routerFactory(deployList, env));
return;
}
if (!options.disableUpdateUnderpostConfig) Underpost.deploy.configMap(env);
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
if (!deployId) continue;
if (options.expose === true) {
const kindType = options.kindType ? options.kindType : 'svc';
const svc = Underpost.kubectl.get(deployId, kindType)[0];
if (!svc) {
logger.error(`No ${kindType} found matching '${deployId}', skipping expose`);
continue;
}
if (options.localProxy) {
const svcPorts = [
...new Set(
svc['PORT(S)']
.split(',')
.filter((p) => p.includes('/TCP'))
.map((p) => parseInt(p.split(':')[0])),
),
];
for (const svcPort of svcPorts) {
shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${svcPort}:${svcPort}`, {
async: true,
});
}
const envFile = `./engine-private/conf/${deployId}/.env.${env}`;
let basePort = svcPorts[0] - 1;
if (fs.existsSync(envFile)) {
const portMatch = fs.readFileSync(envFile, 'utf8').match(/^PORT=(\d+)/m);
if (portMatch) basePort = parseInt(portMatch[1]);
}
logger.info(deployId, { svc, svcPorts, basePort });
const tlsFlag = options.tls ? ' tls' : '';
shellExec(
`NODE_ENV=${env} PORT=${basePort} DEV_PROXY_PORT_OFFSET=0 node src/proxy proxy ${deployId} ${env}${tlsFlag}`,
{ async: true },
);
} else {
const remotePort = options.exposePort
? parseInt(options.exposePort)
: options.port
? parseInt(options.port)
: kindType !== 'svc'
? 80
: parseInt(svc[`PORT(S)`].split('/TCP')[0]);
const localPort = options.exposeLocalPort ? parseInt(options.exposeLocalPort) : remotePort;
logger.info(deployId, {
svc,
localPort,
remotePort,
});
shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${localPort}:${remotePort}`, {
async: true,
});
}
continue;
}
const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
: [];
if (!options.disableUpdateDeployment)
for (const version of options.versions.split(',')) {
shellExec(
`sudo kubectl delete svc ${deployId}-${env}-${version}-service -n ${namespace} --ignore-not-found`,
);
shellExec(
`sudo kubectl delete deployment ${deployId}-${env}-${version} -n ${namespace} --ignore-not-found`,
);
if (!options.disableUpdateVolume)
for (const volume of confVolume)
Underpost.deploy.deployVolume(volume, {
deployId,
env,
version,
namespace,
nodeName: Underpost.deploy.resolveDeployNode({
node: options.node,
kind: options.kind,
kubeadm: options.kubeadm,
k3s: options.k3s,
env,
}),
clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
gitClean: options.gitClean || false,
sshKeyPath: options.sshKeyPath || '',
});
}
for (const host of Object.keys(confServer)) {
if (!options.disableUpdateProxy) {
shellExec(`sudo kubectl delete HTTPProxy ${host} -n ${namespace} --ignore-not-found`);
if (Underpost.deploy.isValidTLSContext({ host, env, options }))
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
}
}
const manifestsPath =
env === 'production'
? `engine-private/conf/${deployId}/build/production`
: `manifests/deployment/${deployId}-${env}`;
if (!options.remove) {
if (!options.disableUpdateDeployment) {
shellExec(`sudo kubectl apply -f ./${manifestsPath}/deployment.yaml -n ${namespace}`);
const grpcServicePath = `./${manifestsPath}/grpc-service.yaml`;
if (fs.existsSync(grpcServicePath)) shellExec(`sudo kubectl apply -f ${grpcServicePath} -n ${namespace}`);
}
if (!options.disableUpdateProxy)
shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
if (
Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }) &&
!options.selfSigned
) {
const secretPath = `./${manifestsPath}/secret.yaml`;
if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
} else logger.info('Skipping secret.yaml apply (no objects yet; applied by the --cert step)');
}
}
}
},
/**
* Creates a Kubernetes Secret for a deployment (replaces configMap for secret data).
* Secrets are mounted as tmpfs (never written to node disk) and support RBAC restrictions.
* @param {string} env - Environment for which the secret is being created.
* @param {string} [namespace='default'] - Kubernetes namespace for the secret.
* @memberof UnderpostDeploy
*/
configMap(env, namespace = 'default') {
const cronDeployId = cronDeployIdResolve() || 'dd-cron';
const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
// `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
// `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
// overrides the image's own and breaks coreutils/sudo resolution inside the pod.
const sanitizedEnvPath = `${envFilePath}.secret`;
fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
shellExec(
`kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
);
fs.removeSync(sanitizedEnvPath);
},
/**
* Switches the traffic for a deployment.
* @param {string} deployId - Deployment ID for which the traffic is being switched.
* @param {string} env - Environment for which the traffic is being switched.
* @param {string} targetTraffic - Target traffic status for the deployment.
* @param {number} replicas - Number of replicas for the deployment.
* @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
* @param {object} options - Options for the traffic switch.
* @param {string} options.timeoutResponse - Timeout response setting for the deployment.
* @param {string} options.timeoutIdle - Timeout idle setting for the deployment.
* @param {string} options.retryCount - Retry count setting for the deployment.
* @param {string} options.retryPerTryTimeout - Retry per-try timeout setting for the deployment.
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override; forwarded to the manifest rebuild triggered here.
* @memberof UnderpostDeploy
*/
switchTraffic(
deployId,
env,
targetTraffic,
replicas = 1,
namespace = 'default',
options = {
timeoutResponse: '',
timeoutIdle: '',
retryCount: '',
retryPerTryTimeout: '',
imagePullPolicy: '',
},
) {
const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
shellExec(
`node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag} ${deployId} ${env}`,
);
shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
const grpcServicePath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
if (fs.existsSync(grpcServicePath)) shellExec(`kubectl apply -f ${grpcServicePath} -n ${namespace}`);
Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
},
/**
* Resolves the effective target node for a deployment, applying a single
* precedence chain shared by every deploy workflow — the default `deploy`
* callback, `run sync`, and custom `run instance` — so node customization
* behaves identically everywhere:
*
* 1. **Explicit node** — `node` (the resolved `--node` value). Upstream
* runners derive it from the comma-path field or `--nod