@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,068 lines (1,005 loc) • 151 kB
JavaScript
/**
* @description The main entry point for the Underpost CLI applications.
* @module src/cli/run.js
* @namespace UnderpostRun
*/
import { daemonProcess, getTerminalPid, pbcopy, shellCd, shellExec } from '../server/process.js';
import crypto from 'crypto';
import {
awaitDeployMonitor,
buildKindPorts,
Config,
cronDeployIdResolve,
etcHostFactory,
getNpmRootPath,
isDeployRunnerContext,
loadConfServerJson,
loadReplicas,
writeEnv,
} from '../server/conf.js';
import { actionInitLog, loggerFactory } from '../server/logger.js';
import fs from 'fs-extra';
import net from 'net';
import { range, s4, setPad, timer } from '../client/components/core/CommonJs.js';
import os from 'os';
import Underpost from '../index.js';
import dotenv from 'dotenv';
import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
const waitForPort = (port, host = '127.0.0.1', { maxAttempts = 30, interval = 2000 } = {}) =>
new Promise((resolve, reject) => {
let attempts = 0;
const tryConnect = () => {
attempts++;
const socket = net.createConnection({ port, host }, () => {
socket.destroy();
resolve();
});
socket.on('error', () => {
socket.destroy();
if (attempts >= maxAttempts) return reject(new Error(`Port ${port} not ready after ${maxAttempts} attempts`));
setTimeout(tryConnect, interval);
});
};
tryConnect();
});
const logger = loggerFactory(import.meta);
/**
* @constant DEFAULT_OPTION
* @description Default options for the UnderpostRun class.
* @typedef {Object} UnderpostRunDefaultOptions
* @type {Object}
* @property {boolean} dev - Whether to run in development mode.
* @property {string} podName - The name of the pod to run.
* @property {string} nodeName - The name of the node to run.
* @property {string} sshKeyPath - Private key path for node SSH operations, forwarded to volume shipping over SSH.
* @property {number} port - Custom port to use.
* @property {string} volumeHostPath - The host path for the volume.
* @property {string} volumeMountPath - The mount path for the volume.
* @property {string} imageName - The name of the image to run.
* @property {string} containerName - The name of the container to run.
* @property {string} namespace - The namespace to run in.
* @property {string} timeoutResponse - The response timeout duration.
* @property {string} timeoutIdle - The idle timeout duration.
* @property {string} retryCount - The number of retries.
* @property {string} retryPerTryTimeout - The timeout duration per retry.
* @property {boolean} build - Whether to build the image.
* @property {number} replicas - The number of replicas to run.
* @property {boolean} force - Whether to force the operation.
* @property {boolean} reset - Whether to reset the operation.
* @property {boolean} tls - Whether to use TLS.
* @property {string} cmd - The command to run in the container.
* @property {string} tty - The TTY option for the container.
* @property {string} stdin - The stdin option for the container.
* @property {string} restartPolicy - The restart policy for the container.
* @property {string} runtimeClassName - The runtime class name for the container.
* @property {string} imagePullPolicy - The image pull policy for the container.
* @property {string} apiVersion - The API version for the container.
* @property {string} claimName - The claim name for the volume.
* @property {string} kindType - The kind of resource to create.
* @property {number} devProxyPortOffset - The port offset for the development proxy.
* @property {boolean} hostNetwork - Whether to use host networking.
* @property {string} requestsMemory - The memory request for the container.
* @property {string} requestsCpu - The CPU request for the container.
* @property {string} limitsMemory - The memory limit for the container.
* @property {string} limitsCpu - The CPU limit for the container.
* @property {string} resourceTemplateId - The resource template ID.
* @property {boolean} expose - Whether to expose the service.
* @property {boolean} etcHosts - Whether to modify /etc/hosts.
* @property {string} confServerPath - The configuration server path.
* @property {string} underpostRoot - The root path of the Underpost installation.
* @property {string} cmdCronJobs - Pre-script commands to run before cron job execution.
* @property {string} deployIdCronJobs - The deployment ID for cron jobs.
* @property {string} timezone - The timezone to set.
* @property {boolean} kubeadm - Whether to run in kubeadm mode.
* @property {boolean} kind - Whether to run in kind mode.
* @property {boolean} k3s - Whether to run in k3s mode.
* @property {string} hosts - The hosts to use.
* @property {string} deployId - The deployment ID.
* @property {string} instanceId - The instance ID.
* @property {string} user - The user to run as.
* @property {string} group - The group to use.
* @property {string} pid - The process ID.
* @property {boolean} disablePrivateConfUpdate - Whether to disable private configuration updates.
* @property {string} monitorStatus - The monitor status option.
* @property {string} monitorStatusKindType - The monitor status kind type option.
* @property {string} monitorStatusDeltaMs - The monitor status delta in milliseconds.
* @property {string} monitorStatusMaxAttempts - The maximum number of attempts for monitor status.
* @property {boolean} logs - Whether to enable logs.
* @property {boolean} dryRun - Whether to perform a dry run.
* @property {boolean} createJobNow - Whether to create the job immediately.
* @property {number} fromNCommit - Number of commits back to use for message propagation (default: 1, last commit only).
* @property {string|Array<{ip: string, hostnames: string[]}>} hostAliases - Adds entries to the Pod /etc/hosts via Kubernetes hostAliases.
* As a string (CLI): semicolon-separated entries of "ip=hostname1,hostname2" (e.g., "127.0.0.1=foo.local,bar.local;10.1.2.3=foo.remote").
* As an array (programmatic): objects with `ip` and `hostnames` fields (e.g., [{ ip: "127.0.0.1", hostnames: ["foo.local"] }]).
* @property {boolean} gitClean - Whether to perform a `git clean` before running.
* @property {boolean} copy - Whether to copy the command to the clipboard instead of executing it.
* @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
* @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
* @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
* @property {boolean} test - Whether to enable test/generic-purpose mode (e.g. use self-signed TLS instead of cert-manager).
* @memberof UnderpostRun
*/
const DEFAULT_OPTION = {
dev: false,
podName: '',
nodeName: '',
sshKeyPath: '',
port: 0,
volumeHostPath: '',
volumeMountPath: '',
imageName: '',
containerName: '',
namespace: 'default',
timeoutResponse: '',
timeoutIdle: '',
retryCount: '',
retryPerTryTimeout: '',
build: false,
replicas: 1,
force: false,
reset: false,
tls: false,
cmd: '',
tty: '',
stdin: '',
restartPolicy: '',
runtimeClassName: '',
imagePullPolicy: '',
apiVersion: '',
claimName: '',
kindType: '',
devProxyPortOffset: 0,
hostNetwork: false,
requestsMemory: '',
requestsCpu: '',
limitsMemory: '',
limitsCpu: '',
resourceTemplateId: '',
expose: false,
etcHosts: false,
confServerPath: '',
underpostRoot: '',
cmdCronJobs: '',
deployIdCronJobs: '',
timezone: '',
kubeadm: false,
kind: false,
k3s: false,
hosts: '',
deployId: '',
instanceId: '',
user: '',
group: '',
pid: '',
disablePrivateConfUpdate: false,
monitorStatus: '',
monitorStatusKindType: '',
monitorStatusDeltaMs: '',
monitorStatusMaxAttempts: '',
logs: false,
dryRun: false,
createJobNow: false,
fromNCommit: 0,
hostAliases: '',
gitClean: false,
copy: false,
skipFullBuild: false,
pullBundle: false,
remove: false,
test: false,
};
/**
* @class UnderpostRun
* @description Manages the execution of various CLI commands and operations.
* This class provides a set of static methods to perform different tasks
* such as running tests, deploying applications, managing environment variables,
* and more. It also includes a default option configuration and a collection of
* runners for executing specific commands.
* @memberof UnderpostRun
*/
class UnderpostRun {
/**
* @static
* @description Collection of runners for executing specific commands.
* @type {Object}
* @memberof UnderpostRun
*/
static RUNNERS = {
/**
* @method dev-cluster
* @description Resets and deploys a full development cluster including MongoDB, Valkey, exposes services, and updates `/etc/hosts` for local access.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'dev-cluster': (path, options = DEFAULT_OPTION) => {
const baseCommand = options.dev ? 'node bin' : 'underpost';
const mongoHosts = ['mongodb-0.mongodb-service'];
let primaryMongoHost = 'mongodb-0.mongodb-service';
if (!options.expose) {
shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --reset`);
shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
shellExec(
`${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --service-host ${mongoHosts.join(
',',
)} --pull-image`,
);
shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --valkey --pull-image`);
}
if (options.k3s) {
if (options.remove) {
shellExec(`${baseCommand} lxd --delete-expose k3s-control:27017`);
shellExec(`${baseCommand} lxd --delete-expose k3s-control:6379`);
} else {
shellExec(`${baseCommand} lxd --expose k3s-control:27017 --node-port 32017`);
shellExec(`${baseCommand} lxd --expose k3s-control:6379 --node-port 32079`);
}
shellExec(`lxc config device show k3s-control`);
} else {
try {
const primaryPodName =
MongoBootstrap.getPrimaryPodName({
namespace: options.namespace,
podName: 'mongodb-0',
disableAuth: options.dev,
}) || 'mongodb-0';
shellExec(
`${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config mongo`,
{ async: true },
);
shellExec(
`${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config valkey`,
{ async: true },
);
} catch (error) {
logger.warn('Failed to detect MongoDB primary pod, using default', {
error: error.message,
default: primaryMongoHost,
});
}
}
const hostListenResult = etcHostFactory([primaryMongoHost]);
logger.info(hostListenResult.renderHosts);
},
/**
* @method etc-hosts
* @description Modifies the `/etc/hosts` file to add entries for local access to services,
* based on the provided path input.
* @param {string} path - The input value, identifier, or path for the operation (used to specify the entries to add to /etc/hosts).
*/
'etc-hosts': (path = '', options = DEFAULT_OPTION) => {
etcHostFactory(path.split(','));
},
/**
* @method ipfs-expose
* @description Exposes IPFS Cluster services on specified ports for local access.
* @type {Function}
* @memberof UnderpostRun
*/
'ipfs-expose': (path, options = DEFAULT_OPTION) => {
const ports = [5001, 9094, 8080];
for (const port of ports)
shellExec(`node bin deploy --expose ipfs-cluster --expose-port ${port} --disable-update-underpost-config`, {
async: true,
});
},
/**
* @method metadata
* @description Generates metadata for the specified path after exposing the development cluster.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
metadata: async (path, options = DEFAULT_OPTION) => {
const ports = '6379,27017';
shellExec(`node bin run kill '${ports}'`);
shellExec(`node bin run dev-cluster --dev --expose --namespace ${options.namespace}`, { async: true });
logger.info('Waiting for port-forward services to be ready...');
try {
await Promise.all([waitForPort(27017), waitForPort(6379)]);
logger.info('Port-forward services are ready');
} catch (err) {
logger.error('Port-forward services failed to become ready', { error: err.message });
shellExec(`node bin run kill '${ports}'`);
throw err;
}
shellExec(`node bin metadata --generate ${path}`);
shellExec(`node bin db --dev --clean-fs-collection dd`);
shellExec(`node bin run kill '${ports}'`);
},
/**
* @method svc-ls
* @description Lists systemd services and installed packages, optionally filtering by the provided path.
* @param {string} path - The input value, identifier, or path for the operation (used as the optional filter for services and packages).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'svc-ls': (path, options = DEFAULT_OPTION) => {
const log = shellExec(`systemctl list-units --type=service${path ? ` | grep ${path}` : ''}`, {
silent: true,
stdout: true,
});
console.log(path ? log.replaceAll(path, path.red) : log);
const log0 = shellExec(`sudo dnf list installed${path ? ` | grep ${path}` : ''}`, {
silent: true,
stdout: true,
});
console.log(path ? log0.replaceAll(path, path.red) : log0);
},
/**
* @method svc-rm
* @description Removes a systemd service by stopping it, disabling it, uninstalling the package, and deleting related files.
* @param {string} path - The input value, identifier, or path for the operation (used as the service name).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'svc-rm': (path, options = DEFAULT_OPTION) => {
shellExec(`sudo systemctl stop ${path}`);
shellExec(`sudo systemctl disable --now ${path}`);
shellExec(`sudo dnf remove -y ${path}*`);
shellExec(`sudo rm -f /usr/lib/systemd/system/${path}.service`);
shellExec(`sudo rm -f /etc/yum.repos.d/${path}*.repo`);
},
/**
* @method ssh-deploy-info
* @description Retrieves deployment status and pod information from a remote server via SSH.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'ssh-deploy-info': async (path = '', options = DEFAULT_OPTION) => {
const env = options.dev ? 'development' : 'production';
await Underpost.ssh.sshRemoteRunner(
`node bin deploy ${path ? path : 'dd'} ${env} --status && kubectl get pods -A`,
{
deployId: options.deployId,
user: options.user,
dev: options.dev,
remote: true,
useSudo: true,
cd: '/home/dd/engine',
},
);
},
/**
* @method node-move
* @description Abstract runner that relocates any schedulable Kubernetes workload
* (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, ReplicationController)
* onto a target node by patching its pod-template `nodeSelector` and rolling it out.
* Resource-kind agnostic: it resolves the kind dynamically and applies the right
* patch path, so it works for `sts`, `deployment`, etc. without bespoke logic.
*
* Selection grammar via `path`:
* - `<kind>/<name>` -> a single resource (e.g. `deployment/dd-core-production-blue`)
* - `<kind>` -> every resource of that kind in the namespace (e.g. `statefulset`)
* - `` -> all movable workloads (deployment, statefulset, daemonset) in the namespace
*
* Placement:
* - default: built-in `kubernetes.io/hostname=<node>` (no node mutation required)
* - `--labels k=v,...`: label the target node with those pairs and use them as the
* nodeSelector (matches the "label node + nodeSelector" pattern), enabling reusable
* workload pools instead of pinning to a single hostname.
*
* Flags: `--node-name <node>` (target), `--namespace <ns>`, `--dry-run` (preview only),
* `--remove` (clear the nodeSelector / unpin placement).
*
* Caveats: Services/ConfigMaps and bare Pods are not schedulable controllers and are
* skipped (move the owning controller). StatefulSets bound to node-local PVs may stay
* Pending after a move until their volume is available on the target node.
* @param {string} path - Resource selector (`kind/name`, `kind`, or empty).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
* @returns {Array<{ref:string,kind:string,status:string,node?:string}>} Per-resource outcome.
*/
'node-move': (path = '', options = DEFAULT_OPTION) => {
const node = options.nodeName;
const ns = options.namespace || 'default';
const dryRun = options.dryRun === true;
const remove = options.remove === true;
if (!remove && !node) {
throw new Error('node-move requires --node-name <target-node> (or --remove to clear placement)');
}
const normalizeKind = (k) =>
({
deploy: 'deployment',
deployments: 'deployment',
deployment: 'deployment',
sts: 'statefulset',
statefulsets: 'statefulset',
statefulset: 'statefulset',
ds: 'daemonset',
daemonsets: 'daemonset',
daemonset: 'daemonset',
rs: 'replicaset',
replicasets: 'replicaset',
replicaset: 'replicaset',
rc: 'replicationcontroller',
replicationcontroller: 'replicationcontroller',
job: 'job',
jobs: 'job',
cj: 'cronjob',
cronjob: 'cronjob',
cronjobs: 'cronjob',
po: 'pod',
pod: 'pod',
pods: 'pod',
svc: 'service',
service: 'service',
services: 'service',
})[k] || k;
// Kinds that own a pod template we can patch; rolloutKinds additionally
// support `kubectl rollout restart` to reschedule existing pods now.
const templated = [
'deployment',
'statefulset',
'daemonset',
'replicaset',
'job',
'cronjob',
'replicationcontroller',
];
const rolloutKinds = ['deployment', 'statefulset', 'daemonset'];
const templateSelectorPath = (kind) =>
kind === 'cronjob'
? ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'nodeSelector']
: ['spec', 'template', 'spec', 'nodeSelector'];
// Resolve the desired nodeSelector. Custom --labels enables reusable pools;
// otherwise pin by the always-present hostname label.
let selector = { 'kubernetes.io/hostname': node };
if (!remove && options.labels) {
selector = {};
for (const pair of `${options.labels}`
.split(',')
.map((s) => s.trim())
.filter(Boolean)) {
const eq = pair.indexOf('=');
if (eq < 0) continue;
selector[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
}
}
// Verify the target node exists, and apply custom labels to it if provided.
if (!remove) {
const found = shellExec(`kubectl get node ${node} -o name`, {
silent: true,
stdout: true,
silentOnError: true,
}).trim();
if (!found) throw new Error(`Target node not found: ${node}`);
if (options.labels) {
const labelArgs = Object.entries(selector)
.map(([k, v]) => `${k}=${v}`)
.join(' ');
const labelCmd = `kubectl label node ${node} ${labelArgs} --overwrite`;
if (dryRun) logger.info(`[dry-run] ${labelCmd}`);
else shellExec(labelCmd);
}
}
const kubectlNames = (kind) =>
(
shellExec(`kubectl get ${kind} -n ${ns} -o name`, {
silent: true,
stdout: true,
silentOnError: true,
}).trim() || ''
)
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
// Build the list of "kind/name" targets from the selection grammar.
let targets = [];
if (!path) {
for (const kind of ['deployment', 'statefulset', 'daemonset']) targets.push(...kubectlNames(kind));
} else if (path.includes('/')) {
targets = [path];
} else {
targets = kubectlNames(path);
}
if (targets.length === 0) {
logger.warn('node-move: no matching resources found', { path, namespace: ns });
return [];
}
// Merge-patch body that sets (or clears, when --remove) the nodeSelector.
const buildPatch = (kind) => {
const keys = templateSelectorPath(kind);
let obj = remove ? null : selector;
for (let i = keys.length - 1; i >= 0; i--) obj = { [keys[i]]: obj };
return JSON.stringify(obj);
};
const results = [];
for (const ref of targets) {
const slash = ref.indexOf('/');
const rawKind = slash >= 0 ? ref.slice(0, slash) : path && !path.includes('/') ? path : '';
const name = slash >= 0 ? ref.slice(slash + 1) : ref;
const kind = normalizeKind(`${rawKind}`.split('.')[0].toLowerCase());
if (!templated.includes(kind)) {
logger.warn(`node-move: ${kind}/${name} is not a schedulable controller; skipping`, {
hint: 'move its owning controller (deployment/statefulset/daemonset) instead',
});
results.push({ ref, kind, status: 'skipped' });
continue;
}
// Idempotency: skip the patch + rollout if the resource is already where
// we want it. Compares the live pod-template nodeSelector against the
// desired placement so a repeated run does not trigger an unnecessary
// rollout restart.
const basePath = kind === 'cronjob' ? 'spec.jobTemplate.spec.template.spec' : 'spec.template.spec';
const jsonpath = (expr) =>
shellExec(`kubectl get ${kind} ${name} -n ${ns} -o jsonpath='${expr}'`, {
silent: true,
stdout: true,
silentOnError: true,
disableLog: true,
}).trim();
if (remove) {
const current = jsonpath(`{.${basePath}.nodeSelector}`);
if (!current || current === 'map[]') {
logger.info(`node-move: ${kind}/${name} already has no nodeSelector; nothing to clear`);
results.push({ ref, kind, status: 'already-cleared' });
continue;
}
} else {
const alreadyOnNode = Object.entries(selector).every(([k, v]) => {
const esc = k.replace(/\./g, '\\.');
return jsonpath(`{.${basePath}.nodeSelector.${esc}}`) === v;
});
if (alreadyOnNode) {
logger.info(`node-move: ${kind}/${name} already pinned to ${node}; skipping`, { namespace: ns });
results.push({ ref, kind, status: 'already-on-node', node });
continue;
}
}
const patchCmd = `kubectl patch ${kind} ${name} -n ${ns} --type=merge -p '${buildPatch(kind)}'`;
const restartCmd = `kubectl rollout restart ${kind} ${name} -n ${ns}`;
if (dryRun) {
logger.info(`[dry-run] ${patchCmd}`);
if (rolloutKinds.includes(kind)) logger.info(`[dry-run] ${restartCmd}`);
results.push({ ref, kind, status: 'dry-run', node: remove ? undefined : node });
continue;
}
shellExec(patchCmd);
if (rolloutKinds.includes(kind)) shellExec(restartCmd);
logger.info(remove ? `Cleared node placement: ${kind}/${name}` : `Moved ${kind}/${name} -> ${node}`, {
namespace: ns,
});
results.push({ ref, kind, status: remove ? 'cleared' : 'moved', node: remove ? undefined : node });
}
logger.info('node-move complete', { namespace: ns, node: remove ? null : node, count: results.length });
return results;
},
/**
* @method dev-hosts-expose
* @description Deploys a specified service in development mode with `/etc/hosts` modification for local access.
* @param {string} path - The input value, identifier, or path for the operation (used as the deployment ID to deploy).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'dev-hosts-expose': (path, options = DEFAULT_OPTION) => {
shellExec(
`node bin deploy ${path} development --disable-update-deployment --disable-update-proxy --kubeadm --etc-hosts`,
);
},
/**
* @method dev-hosts-restore
* @description Restores the `/etc/hosts` file to its original state after modifications made during development deployments.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'dev-hosts-restore': (path, options = DEFAULT_OPTION) => {
shellExec(`node bin deploy --restore-hosts`);
},
/**
* @method cluster-build
* @description Build configuration for cluster deployment.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'cluster-build': (path, options = DEFAULT_OPTION) => {
const nodeOptions =
(options.nodeName ? ` --node-name ${options.nodeName}` : '') +
(options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '');
shellExec(`node bin run clean`);
shellExec(`node bin run --dev sync-replica template-deploy${nodeOptions}`);
shellExec(`node bin run sync-replica template-deploy${nodeOptions}`);
shellExec(`node bin env clean`);
for (const deployId of fs.readFileSync('./engine-private/deploy/dd.router', 'utf8').split(','))
shellExec(`node bin new --default-conf --deploy-id ${deployId.trim()}`);
if (path === 'cmt') {
shellExec(`git add . && underpost cmt . build cluster-build`);
shellExec(`cd engine-private && git add . && underpost cmt . build cluster-build`);
}
},
/**
* @method template-deploy
* @description Pushes `engine-private`, dispatches CI workflow to build `pwa-microservices-template`,
* and optionally triggers engine-<conf-id> CI with sync/init which in turn dispatches the CD workflow
* after the build chain completes (template → ghpkg → engine-<conf-id> → CD).
* @param {string} path - The deployment path identifier (e.g., 'sync-engine-core', 'init-engine-core', or empty for build-only).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'template-deploy': (path = '', options = DEFAULT_OPTION) => {
const baseCommand = options.dev ? 'node bin' : 'underpost';
shellExec(`npm run security:secrets`);
const reportPath = './gitleaks-report.json';
if (fs.existsSync(reportPath) && JSON.parse(fs.readFileSync(reportPath, 'utf8')).length > 0) {
logger.error('Secrets detected in gitleaks-report.json, aborting template-deploy');
return;
}
shellExec(`${baseCommand} run pull`);
shellExec(`${baseCommand} run shared-dir`);
// Capture the sanitized message from the last N commits (--from-n-commit, default 1) for
// propagation to pwa-microservices-template and every engine-* repo.
const fromN = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
const sanitizedMessage = shellExec(`node bin cmt --changelog-msg --from-n-commit ${fromN} --changelog-no-hash`, {
silent: true,
stdout: true,
}).trim();
shellExec(
`${baseCommand} push ./engine-private ${options.force ? '-f ' : ''}${
process.env.GITHUB_USERNAME
}/engine-private`,
);
shellCd('/home/dd/engine');
// Push engine repo so workflow YAML changes reach GitHub
shellExec(`git reset`);
shellExec(`${baseCommand} push . ${options.force ? '-f ' : ''}${process.env.GITHUB_USERNAME}/engine`);
// Determine deploy conf and type from path (sync-engine-core, init-engine-core, etc.)
let deployConfId = '';
let deployType = '';
if (path.startsWith('sync-')) {
deployConfId = path.replace(/^sync-/, '');
deployType = 'sync-and-deploy';
} else if (path.startsWith('init-')) {
deployConfId = path.replace(/^init-/, '');
deployType = 'init';
}
// If --build is set and path is a sync-engine-* target, push the pre-built client bundle
// to Cloudinary so the remote container can pull it instead of rebuilding from source.
if (options.build && deployConfId && deployConfId.startsWith('engine-')) {
const confName = deployConfId.replace(/^engine-/, '');
const pushDeployId = options.deployId || `dd-${confName}`;
logger.info(`[template-deploy] Running push-bundle for deployId: ${pushDeployId}`);
shellExec(`${baseCommand} run push-bundle --deploy-id ${pushDeployId}`);
}
// Dispatch npmpkg CI workflow — this builds pwa-microservices-template first.
// If deployConfId is set, npmpkg.ci.yml will dispatch the engine-<conf-id> CI
// with sync=true after template build completes. The engine CI then dispatches
// the CD workflow after the engine repo build finishes — ensuring correct sequence:
// npmpkg.ci → engine-<id>.ci → engine-<id>.cd
const repo = `${process.env.GITHUB_USERNAME}/engine`;
const inputs = {};
if (sanitizedMessage) inputs.message = sanitizedMessage;
if (deployConfId) inputs.deploy_conf_id = deployConfId;
if (deployType) inputs.deploy_type = deployType;
Underpost.repo.dispatchWorkflow({
repo,
workflowFile: 'npmpkg.ci.yml',
ref: 'master',
inputs,
});
},
/**
* @method template-deploy-local
* @description Similar to `template-deploy` but runs the workflow locally without dispatching GitHub Actions. It pulls the latest changes, pushes to GitHub, builds the template, and optionally triggers a local release with CI push.
* @param {string} path - The deployment path identifier (e.g., 'sync-engine-core', 'init-engine-core', or empty for build-only).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'template-deploy-local': async (path, options = DEFAULT_OPTION) => {
const baseCommand = options.dev ? 'node bin' : 'underpost';
shellExec(`npm run security:secrets`);
const reportPath = './gitleaks-report.json';
if (fs.existsSync(reportPath) && JSON.parse(fs.readFileSync(reportPath, 'utf8')).length > 0) {
logger.error('Secrets detected in gitleaks-report.json, aborting template-deploy');
return;
}
shellExec(`${baseCommand} run pull`);
shellExec(`${baseCommand} run shared-dir`);
// Capture the sanitized message from the last N commits (--from-n-commit, default 1).
const fromN = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
const sanitizedMessage = shellExec(`node bin cmt --changelog-msg --from-n-commit ${fromN} --changelog-no-hash`, {
silent: true,
stdout: true,
}).trim();
const { triggerCmd } = path
? await Underpost.release.ci(path, sanitizedMessage, options)
: await Underpost.release.pwa(sanitizedMessage, options);
pbcopy(triggerCmd + ' && cd /home/dd/engine');
},
/**
* @method docker-image
* @description Dispatches the Docker image CI workflow (`docker-image[.<runtime>].ci.yml`) via `workflow_dispatch`.
* Repository resolution is delegated to `Underpost.repo.resolveInstanceRepo(path)`.
* @param {string} path - Optional runtime / workflow suffix (e.g. `cyberia-server`, `cyberia-client`).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'docker-image': (path, options = DEFAULT_OPTION) => {
const repo = Underpost.repo.resolveInstanceRepo(path, options.dev);
Underpost.repo.dispatchWorkflow({
repo,
workflowFile: `docker-image${path ? `.${path}` : ''}${options.dev ? '.dev' : ''}.ci.yml`,
inputs: {},
});
},
/**
* @method clean
* @description Changes directory to the provided path (defaulting to `/home/dd/engine`) and runs `node bin/deploy clean-core-repo`.
* @param {string} path - The input value, identifier, or path for the operation (used as the optional directory path).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
clean: (path = '', options = DEFAULT_OPTION) => {
Underpost.repo.clean({ paths: path ? path.split(',') : ['/home/dd/engine', '/home/dd/engine/engine-private'] });
if (options.dev) shellExec(`node bin run shared-dir ${path ? path : '/home/dd/engine'}`);
},
/**
* @method pull
* @description Clones or pulls updates for the `engine` and `engine-private` repositories into `/home/dd/engine` and `/home/dd/engine/engine-private`.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
pull: (path, options = DEFAULT_OPTION) => {
// shellExec is fail-fast by default — any non-zero exit throws and
// propagates up to the workflow step. No per-call flag required.
if (!fs.existsSync(`/home/dd`) || !fs.existsSync(`/home/dd/engine`)) {
fs.mkdirSync(`/home/dd`, { recursive: true });
shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
} else {
shellExec(`underpost run clean`);
shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
}
if (!fs.existsSync(`/home/dd/engine/engine-private`))
shellExec(`cd /home/dd/engine && underpost clone ${process.env.GITHUB_USERNAME}/engine-private`, {
silent: true,
});
else
shellExec(
`cd /home/dd/engine/engine-private && underpost pull . ${process.env.GITHUB_USERNAME}/engine-private`,
{ silent: true },
);
},
/**
* @method release-deploy
* @description Executes deployment (`underpost run deploy`) for all deployment IDs listed in `./engine-private/deploy/dd.router`.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'release-deploy': (path, options = DEFAULT_OPTION) => {
actionInitLog();
shellExec(`underpost --version`);
shellCd(`/home/dd/engine`);
for (const _deployId of fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(',')) {
const deployId = _deployId.trim();
shellExec(`underpost run deploy ${deployId}`, { async: true });
}
},
/**
* @method ssh-deploy
* @description Dispatches the corresponding CD workflow for SSH-based deployment, replacing empty commits with workflow_dispatch.
* @param {string} path - The deployment identifier (e.g., 'engine-core', 'sync-engine-core', 'init-engine-core').
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'ssh-deploy': (path, options = DEFAULT_OPTION) => {
actionInitLog();
let job = 'deploy';
let confId = path;
if (path.startsWith('sync-')) {
job = 'sync-and-deploy';
confId = path.replace(/^sync-/, '');
} else if (path.startsWith('init-')) {
job = 'init';
confId = path.replace(/^init-/, '');
}
const repo = Underpost.repo.resolveInstanceRepo(confId, options.dev);
Underpost.repo.dispatchWorkflow({
repo,
workflowFile: `${confId}.cd.yml`,
ref: 'master',
inputs: { job },
});
},
/**
* @method ide
* @description Opens a Visual Studio Code (VS Code) session for the specified path using `node ${underpostRoot}/bin/zed ${path}`,
* or installs Zed and sublime-text IDE if `path` is 'install'.
* @param {string} path - The input value, identifier, or path for the operation (used as the path to the directory to open in the IDE).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
ide: (path = '', options = DEFAULT_OPTION) => {
const underpostRoot = options.dev ? '.' : options.underpostRoot;
const [projectPath, customIde] = path.split(',');
if (projectPath === 'install') {
if (customIde === 'zed') shellExec(`sudo curl -f https://zed.dev/install.sh | sh`);
else if (customIde === 'subl') {
shellExec(
`sudo dnf config-manager --add-repo https://download.sublimetext.com/rpm/stable/x86_64/sublime-text.repo`,
);
shellExec(`sudo dnf install -y sublime-text`);
} else {
shellExec(`sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc &&
echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com/yumrepos/vscode\nenabled=1\nautorefresh=1\ntype=rpm-md\ngpgcheck=1\ngpgkey=https://packages.microsoft.com/keys/microsoft.asc" | sudo tee /etc/yum.repos.d/vscode.repo > /dev/null`);
shellExec(`sudo dnf install -y code`);
}
return;
}
if (customIde === 'zed') shellExec(`node ${underpostRoot}/bin/zed ${projectPath}`);
else shellExec(`node ${underpostRoot}/bin/vs ${projectPath}`);
},
/**
* @method crypto-policy
* @description Sets the system's crypto policies to `DEFAULT:SHA1` using `update-crypto-policies` command.
* @param {string} path - The input value, identifier, or path for the operation.
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'crypto-policy': (path, options = DEFAULT_OPTION) => {
shellExec(`sudo update-crypto-policies --set DEFAULT:SHA1`);
},
/**
* @method sync
* @description Cleans up, and then runs a deployment synchronization command (`underpost deploy --kubeadm --build-manifest --sync...`) using parameters parsed from `path` (deployId, replicas, versions, image, node).
*
* Forwards `--image-pull-policy <policy>` to the underlying `deploy --build-manifest` invocation when `options.imagePullPolicy` is set,
* which then plumbs through `buildManifest` and `deploymentYamlPartsFactory` to override the container `imagePullPolicy` in the generated
* `deployment.yaml`. Useful when you want to force `Always` so the kubelet re-pulls a mutable tag on every rollout. Example:
* `node bin run sync dd-core --kubeadm --image-pull-policy Always`
* @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string containing deploy parameters).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
sync: async (path, options = DEFAULT_OPTION) => {
// Dev usage: node bin run --dev --build sync dd-default
const env = options.dev ? 'development' : 'production';
const baseCommand = 'node bin'; // options.dev ? 'node bin' : 'underpost';
const baseClusterCommand = options.dev ? ' --dev' : '';
const clusterFlag = options.k3s ? ' --k3s' : options.kind ? ' --kind' : ' --kubeadm';
const defaultPath = [
'dd-default',
options.replicas,
``,
``,
!options.kubeadm && !options.k3s ? 'kind-control-plane' : os.hostname(),
];
let [deployId, replicas, versions, image, node] = path ? path.split(',') : defaultPath;
deployId = deployId ? deployId : defaultPath[0];
replicas = replicas ? replicas : defaultPath[1];
versions = versions ? versions.replaceAll('+', ',') : defaultPath[2];
image = image ? image : defaultPath[3];
node = node ? node : options.nodeName ? options.nodeName : defaultPath[4];
shellExec(`${baseCommand} cluster --ns-use ${options.namespace}`);
if (image && !image.startsWith('localhost'))
Underpost.image.pullDockerHubImage({
dockerhubImage: image,
kind: options.kind || (!options.nodeName && !options.kubeadm && !options.k3s),
kubeadm: options.nodeName || options.kubeadm,
k3s: options.k3s,
});
if (isDeployRunnerContext(path, options)) {
if (!options.disablePrivateConfUpdate) {
const { validVersion } = Underpost.repo.privateConfUpdate(deployId);
if (!validVersion) throw new Error('Version mismatch');
}
if (options.timezone !== 'none') shellExec(`${baseCommand} run${baseClusterCommand} tz`);
if (options.deployIdCronJobs !== 'none')
shellExec(`node bin cron${baseClusterCommand}${clusterFlag} --setup-start --git --apply`);
}
const currentTraffic = isDeployRunnerContext(path, options)
? Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace })
: '';
let targetTraffic = currentTraffic ? (currentTraffic === 'blue' ? 'green' : 'blue') : 'green';
if (targetTraffic) versions = versions ? versions : targetTraffic;
const ignorePods =
isDeployRunnerContext(path, options) && targetTraffic
? Underpost.kubectl.get(`${deployId}-${env}-${targetTraffic}`, 'pods', options.namespace).map((p) => p.NAME)
: [];
const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
const cmdString = options.cmd
? ' --cmd ' + (options.cmd.find((c) => c.match('"')) ? '"' + options.cmd + '"' : "'" + options.cmd + "'")
: '';
const gitCleanFlag = options.gitClean ? ' --git-clean' : '';
const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
const sshKeyPathFlag = options.sshKeyPath ? ` --ssh-key-path ${options.sshKeyPath}` : '';
shellExec(
`${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
image ? ` --image ${image}` : ''
}${versions ? ` --versions ${versions}` : ''}${
options.namespace ? ` --namespace ${options.namespace}` : ''
}${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag}${sshKeyPathFlag} ${deployId} ${env}`,
);
if (isDeployRunnerContext(path, options)) {
// Backup app/services repositories with repo-backup configured
shellExec(
`${baseCommand} db ${deployId} ${clusterFlag}${baseClusterCommand} --repo-backup --primary-pod --git --force-clone --preserveUUID ${options.namespace ? ` --ns ${options.namespace}` : ''}`,
);
shellExec(
`${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --node ${node} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
options.namespace ? ` --namespace ${options.namespace}` : ''
}${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}${sshKeyPathFlag}`,
);
if (!targetTraffic)
targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
await Underpost.monitor.monitorReadyRunner(deployId, env, targetTraffic, ignorePods, options.namespace);
Underpost.deploy.switchTraffic(deployId, env, targetTraffic, replicas, options.namespace, options);
} else
logger.info('current traffic', Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace }));
},
/**
* @method stop
* @description Stops a deployment by deleting the corresponding Kubernetes deployment and service resources.
* @param {string} path - The input value, identifier, or path for the operation (used to determine which traffic to stop).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
stop: async (path = '', options = DEFAULT_OPTION) => {
let currentTraffic = Underpost.deploy.getCurrentTraffic(options.deployId, {
namespace: options.namespace,
hostTest: options.hosts,
});
const env = options.dev ? 'development' : 'production';
if (!path.match('current')) currentTraffic === 'blue' ? (currentTraffic = 'green') : (currentTraffic = 'blue');
const [_deployId] = path.split(',');
const deploymentId = `${_deployId ? _deployId : options.deployId}${
options.instanceId ? `-${options.instanceId}` : ''
}-${env}-${currentTraffic}`;
shellExec(`kubectl delete deployment ${deploymentId} -n ${options.namespace}`);
shellExec(`kubectl delete svc ${deploymentId}-service -n ${options.namespace}`);
},
/**
* @method ssh-deploy-stop
* @description Stops a remote deployment via SSH by executing the appropriate Underpost command on the remote server.
* @param {string} path - The input value, identifier, or path for the operation (used to determine which traffic to stop).
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @memberof UnderpostRun
*/
'ssh-deploy-stop': async (path, options = DEFAULT_OPTION) => {
const baseCommand = options.dev ? 'node bin' : 'underpost';
const baseClusterCommand = options.dev ? ' --dev' : '';
const remoteCommand = [
`${baseCommand} run${baseClusterCommand} stop${path ? ` ${path}` : ''}`,
` --deploy-id ${options.deployId}${options.instanceId ? ` --instance-id ${options.instanceId}` : ''}`,
` --namespace ${options.namespace}${options.hosts ? ` --hosts ${options.hosts}` : ''}`,
].join('');
await Underpost.ssh.sshRemoteRunner(remoteCommand, {
deployId: options.deployId,
user: options.user,
dev: options.dev,
remote: true,
useSudo: true,
cd: '/home/dd/engine',
});
},
/**
* @method ssh-deploy-db-rollback
* @description Performs a database rollback on remote deployment via SSH.
* @param {string} path - Comma-separated deployId and optional number of commits to reset (format: "deployId,nCommits")
* @param {UnderpostRunDefaultOptions} options - The default underpost runner options for customizing workflow
* @param {string} options.deployId - The deployment identifier
* @param {string} options.user - The SSH user for credential lookup
* @param {boolean} options.dev - Development mode flag
* @memberof UnderpostRun
*/
'ssh-deploy-db-rollback': async (path = '', options = DEFAULT_OPTION) => {
const baseCommand = options.dev ? 'node bin' : 'underpost';
let [deployId, nCommitsReset] = path.split(',');
if (!nCommitsReset) nCommitsReset = 1;
const remoteCommand = `${baseCommand} db ${deployId} --git --kubeadm --primary-pod --force-clone --macro-rollback-export ${nCommitsReset}${options.namespace ? ` --ns ${options.namespace}` : ''}`;
await Underpost.ssh.sshRemoteRunner(remoteCommand, {
deployId: options.deployId,
user: options.user,
dev: options.dev,
remote: true,
useSudo: true,
cd: '/home/dd/engine',
});
},
/**
* @method ssh-