@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
477 lines (427 loc) • 19 kB
JavaScript
/**
* UnderpostCron server module
* @module src/server/cron.js
* @namespace UnderpostCron
*/
import { loggerFactory } from './logger.js';
import { shellExec } from './process.js';
import fs from 'fs-extra';
import Underpost from '../index.js';
import { getUnderpostRootPath, loadCronDeployEnv } from './conf.js';
const logger = loggerFactory(import.meta);
const volumeHostPath = '/home/dd';
const enginePath = '/home/dd/engine';
const cronVolumeName = 'underpost-cron-container-volume';
const shareEnvVolumeName = 'underpost-share-env';
const underpostContainerEnvDir = '/usr/lib/node_modules/underpost';
/**
* Generates a Kubernetes CronJob YAML manifest string.
*
* @param {Object} params - CronJob parameters
* @param {string} params.name - CronJob name (max 52 chars, sanitized to DNS subdomain)
* @param {string} params.expression - Cron schedule expression (e.g., '0 0 * * *')
* @param {string} params.deployList - Comma-separated deploy IDs for the cron CLI
* @param {string} params.jobList - Comma-separated job IDs (e.g., 'dns', 'backup')
* @param {string} [params.image] - Container image (defaults to underpost/underpost-engine:<version>)
* @param {string} [params.namespace='default'] - Kubernetes namespace
* @param {boolean} [params.git=false] - Pass --git flag to cron CLI
* @param {boolean} [params.dev=false] - Use local ./ base path instead of global underpost installation
* @param {string} [params.cmd] - Optional pre-script commands to run before cron execution
* @param {boolean} [params.suspend=false] - Whether the CronJob is suspended
* @param {boolean} [params.dryRun=false] - Pass --dry-run flag to the cron command inside the container
* @param {boolean} [params.k3s=false] - Pass --k3s flag to the cron command inside the container
* @param {boolean} [params.kind=false] - Pass --kind flag to the cron command inside the container
* @param {boolean} [params.kubeadm=false] - Pass --kubeadm flag to the cron command inside the container
* @returns {string} Kubernetes CronJob YAML manifest
* @memberof UnderpostCron
*/
const cronJobYamlFactory = ({
name,
expression,
deployList,
jobList,
image,
namespace = 'default',
git = false,
dev = false,
cmd,
suspend = false,
dryRun = false,
k3s = false,
kind = false,
kubeadm = false,
}) => {
const containerImage = image || `underpost/underpost-engine:${Underpost.version}`;
const sanitizedName = name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/--+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 52);
const cronBin = 'node bin'; // dev ? 'node bin' : 'underpost';
const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${k3s ? '--k3s ' : ''}${kind ? '--kind ' : ''}${kubeadm ? '--kubeadm ' : ''}`;
const commands = [`cd ${enginePath}`]; // `node bin run secret`
if (cmd) commands.push(cmd);
commands.push(`${cronBin} cron ${deployList} ${jobList} ${flags}`);
const fullCommand = commands.join(' &&\n ');
return `apiVersion: batch/v1
kind: CronJob
metadata:
name: ${sanitizedName}
namespace: ${namespace}
labels:
app: ${sanitizedName}
managed-by: underpost
spec:
schedule: "${expression}"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 200
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
suspend: ${suspend}
jobTemplate:
spec:
template:
metadata:
labels:
app: ${sanitizedName}
managed-by: underpost
spec:
containers:
- name: ${sanitizedName}
image: ${containerImage}
command:
- /bin/sh
- -c
- >
${fullCommand}
volumeMounts:
- mountPath: ${enginePath}
name: ${cronVolumeName}
- mountPath: ${underpostContainerEnvDir}
name: ${shareEnvVolumeName}
volumes:
- hostPath:
path: ${enginePath}
type: Directory
name: ${cronVolumeName}
- hostPath:
path: ${getUnderpostRootPath()}
type: DirectoryOrCreate
name: ${shareEnvVolumeName}
restartPolicy: OnFailure
`;
};
/**
* Syncs the engine directory into the kind-worker container node.
* Required for kind clusters where worker nodes don't share the host filesystem.
*
* @memberof UnderpostCron
*/
const syncEngineToKindWorker = () => {
logger.info('Syncing engine volume to kind-worker node');
shellExec(`docker exec -i kind-worker bash -c "rm -rf ${volumeHostPath}"`);
shellExec(`docker exec -i kind-worker bash -c "mkdir -p ${volumeHostPath}"`);
shellExec(`docker cp ${volumeHostPath}/engine kind-worker:${volumeHostPath}/engine`);
shellExec(
`docker exec -i kind-worker bash -c "chown -R 1000:1000 ${volumeHostPath}; chmod -R 755 ${volumeHostPath}"`,
);
};
/**
* Resolves the deploy-id to use for cron job generation.
* When deployId is provided directly, uses it. Otherwise reads from dd.cron file.
*
* @param {string} [deployId] - Explicit deploy-id override
* @memberof UnderpostCron
* @returns {string|null} Resolved deploy-id or null if not found
*/
const resolveDeployId = (deployId) => {
if (deployId) return deployId;
const cronDeployFilePath = './engine-private/deploy/dd.cron';
if (!fs.existsSync(cronDeployFilePath)) {
return null;
}
return fs.readFileSync(cronDeployFilePath, 'utf8').trim();
};
/**
* UnderpostCron main module methods
* @class UnderpostCron
* @memberof UnderpostCron
*/
class UnderpostCron {
/** @returns {Object} Available cron job handlers */
static get JOB() {
return {
dns: Underpost.dns,
backup: Underpost.backup,
};
}
static API = {
/**
* CLI entry point for the `underpost cron` command.
*
* @param {string} deployList - Comma-separated deploy IDs
* @param {string} jobList - Comma-separated job IDs
* @param {Object} options - CLI flags
* @param {boolean} [options.generateK8sCronjobs] - Generate K8s CronJob YAML manifests
* @param {boolean} [options.apply] - Apply manifests to the cluster
* @param {boolean} [options.git] - Pass --git to job execution
* @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
* @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
* @param {string} [options.namespace] - Kubernetes namespace
* @param {string} [options.image] - Custom container image
* @param {string} [options.setupStart] - Deploy-id to setup: updates its package.json start and generates+applies cron jobs
* @param {boolean} [options.k3s] - Use k3s cluster context (apply directly on host)
* @param {boolean} [options.kind] - Use kind cluster context (apply via kind-worker container)
* @param {boolean} [options.kubeadm] - Use kubeadm cluster context (apply directly on host)
* @param {boolean} [options.dryRun] - Preview cron jobs without executing them
* @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob (requires --apply)
* @memberof UnderpostCron
*/
callback: async function (
deployList = 'default',
jobList = Object.keys(Underpost.cron.JOB).join(','),
options = {},
) {
loadCronDeployEnv();
if (options.setupStart) return await Underpost.cron.setupDeployStart(options.setupStart, options);
if (options.generateK8sCronjobs) return await Underpost.cron.generateK8sCronJobs(options);
for (const _jobId of jobList.split(',')) {
const jobId = _jobId.trim();
if (Underpost.cron.JOB[jobId]) {
if (options.dryRun) {
logger.info(`[dry-run] Would execute cron job`, { jobId, deployList, options });
} else {
logger.info(`Executing cron job`, { jobId, deployList, options });
await Underpost.cron.JOB[jobId].callback(deployList, options);
}
} else {
logger.warn(`Unknown cron job: ${jobId}`);
}
}
},
/**
* Update the package.json start script for the given deploy-id and generate+apply its K8s CronJob manifests.
*
* @param {string} deployId - The deploy-id whose package.json will be updated
* @param {Object} [options] - Additional options forwarded to generateK8sCronJobs
* @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob
* @param {boolean} [options.dryRun] - Pass --dry-run=client to kubectl commands
* @param {boolean} [options.apply] - Whether to apply generated manifests to the cluster
* @param {boolean} [options.git] - Pass --git flag to cron CLI commands
* @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
* @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
* @param {string} [options.namespace] - Kubernetes namespace for the CronJobs
* @param {string} [options.image] - Custom container image override for the CronJobs
* @param {boolean} [options.k3s] - k3s cluster context (apply directly on host)
* @param {boolean} [options.kind] - kind cluster context (apply via kind-worker container)
* @param {boolean} [options.kubeadm] - kubeadm cluster context (apply directly on host)
* @memberof UnderpostCron
*/
setupDeployStart: async function (deployId, options = {}) {
if (!deployId || deployId === true) deployId = resolveDeployId();
const confDir = `./engine-private/conf/${deployId}`;
const packageJsonPath = `${confDir}/package.json`;
const confCronPath = `${confDir}/conf.cron.json`;
if (!fs.existsSync(confCronPath)) {
logger.warn(`conf.cron.json not found for deploy-id: ${deployId}`, { path: confCronPath });
return;
}
const confCron = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
if (!confCron.jobs || Object.keys(confCron.jobs).length === 0) {
logger.warn(`No cron jobs configured for deploy-id: ${deployId}`);
return;
}
const hasEnabledJobs = Object.values(confCron.jobs).some((job) => job.enabled !== false);
if (!hasEnabledJobs) {
logger.warn(`No enabled cron jobs for deploy-id: ${deployId}`);
return;
}
// Update package.json start script
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
let startCommand = 'echo "Starting cron jobs..."';
for (const job of Object.keys(confCron.jobs))
startCommand += ` && kubectl apply -f ./manifests/cronjobs/${deployId}/${deployId}-${job}.yaml`;
if (!packageJson.scripts) packageJson.scripts = {};
packageJson.scripts.start = startCommand;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + '\n', 'utf8');
logger.info(`Updated package.json start script for ${deployId}`, { path: packageJsonPath });
} else {
logger.warn(`package.json not found for deploy-id: ${deployId}`, { path: packageJsonPath });
}
await Underpost.cron.generateK8sCronJobs({
deployId,
namespace: options.namespace,
image: options.image,
apply: options.apply,
createJobNow: options.createJobNow,
git: !!options.git,
dev: !!options.dev,
kubeadm: !!options.kubeadm,
cmd: options.cmd || `node bin env ${deployId} production`,
k3s: !!options.k3s,
kind: !!options.kind,
dryRun: !!options.dryRun,
});
},
/**
* Generate Kubernetes CronJob YAML manifests from conf.cron.json configuration.
* Each enabled job produces one CronJob YAML file under manifests/cronjobs/<deployId>/.
* With --apply the manifests are also applied to the cluster via kubectl.
*
* @param {Object} options
* @param {string} [options.deployId] - Explicit deploy-id (overrides dd.cron file lookup)
* @param {boolean} [options.git=false] - Pass --git flag to cron CLI commands
* @param {boolean} [options.dev=false] - Use local ./ base path instead of global underpost
* @param {string} [options.cmd] - Optional pre-script commands
* @param {boolean} [options.apply=false] - kubectl apply generated manifests
* @param {string} [options.namespace='default'] - Target Kubernetes namespace
* @param {string} [options.image] - Custom container image override
* @param {boolean} [options.k3s=false] - k3s cluster context (apply directly on host)
* @param {boolean} [options.kind=false] - kind cluster context (apply via kind-worker container)
* @param {boolean} [options.kubeadm=false] - kubeadm cluster context (apply directly on host)
* @param {boolean} [options.createJobNow=false] - After applying, create a Job from each CronJob immediately
* @param {boolean} [options.dryRun=false] - Pass --dry-run=client to kubectl commands
* @memberof UnderpostCron
*/
generateK8sCronJobs: async function (options = {}) {
const namespace = options.namespace || 'default';
const jobDeployId = resolveDeployId(options.deployId);
if (!jobDeployId) {
logger.warn(
'Could not resolve deploy-id. Provide --setup-start <deploy-id> or create engine-private/deploy/dd.cron',
);
return;
}
const confCronPath = `./engine-private/conf/${jobDeployId}/conf.cron.json`;
if (!fs.existsSync(confCronPath)) {
logger.warn(`Cron configuration not found: ${confCronPath}`);
return;
}
const confCronConfig = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
if (!confCronConfig.jobs || Object.keys(confCronConfig.jobs).length === 0) {
logger.info('No cron jobs configured');
return;
}
const outputDir = `./manifests/cronjobs/${jobDeployId}`;
fs.mkdirSync(outputDir, { recursive: true });
const generatedFiles = [];
for (const job of Object.keys(confCronConfig.jobs)) {
const jobConfig = confCronConfig.jobs[job];
if (jobConfig.enabled === false) {
logger.info(`Skipping disabled job: ${job}`);
continue;
}
const deployIdList = Underpost.cron.getRelatedDeployIdList(job);
const expression = jobConfig.expression || '0 0 * * *';
const cronJobName = `${jobDeployId}-${job}`;
const yamlContent = cronJobYamlFactory({
name: cronJobName,
expression,
deployList: deployIdList,
jobList: job,
image: options.image,
namespace,
git: !!options.git,
dev: !!options.dev,
cmd: options.cmd,
suspend: false,
dryRun: !!options.dryRun,
k3s: !!options.k3s,
kind: !!options.kind,
kubeadm: !!options.kubeadm,
});
const yamlFilePath = `${outputDir}/${cronJobName}.yaml`;
fs.writeFileSync(yamlFilePath, yamlContent, 'utf8');
generatedFiles.push(yamlFilePath);
logger.info(`Generated CronJob manifest: ${yamlFilePath}`, { job, expression, namespace });
}
if (options.apply) {
// Delete existing CronJobs before applying new ones
for (const job of Object.keys(confCronConfig.jobs)) {
const cronJobName = `${jobDeployId}-${job}`;
shellExec(`kubectl delete cronjob ${cronJobName} --namespace=${namespace} --ignore-not-found`);
}
// Ensure default dockerhub image is loaded on the cluster when no custom image is provided
if (!options.image) {
logger.info('Ensuring default image is loaded on cluster');
Underpost.image.pullDockerHubImage({
dockerhubImage: 'underpost',
kind: !!options.kind,
k3s: !!options.k3s,
kubeadm: !!options.kubeadm,
dev: !!options.dev,
});
}
// Sync engine volume to kind-worker node if using kind cluster
if (options.kind) {
syncEngineToKindWorker();
}
for (const yamlFile of generatedFiles) {
logger.info(`Applying: ${yamlFile}`);
shellExec(`kubectl apply -f ${yamlFile}`);
}
logger.info('All CronJob manifests applied');
} else {
logger.info(`Manifests generated in ${outputDir}. Use --apply to deploy to the cluster.`);
}
// Create an immediate Job from each CronJob if requested
if (options.createJobNow) {
for (const job of Object.keys(confCronConfig.jobs)) {
const jobConfig = confCronConfig.jobs[job];
if (jobConfig.enabled === false) continue;
const cronJobName = `${jobDeployId}-${job}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/--+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 52);
const immediateJobName = `${cronJobName}-now-${Date.now()}`.substring(0, 63);
logger.info(`Creating immediate Job from CronJob: ${cronJobName}`, { jobName: immediateJobName });
shellExec(`kubectl create job ${immediateJobName} --from=cronjob/${cronJobName} -n ${namespace}`);
}
logger.info('All immediate Jobs created');
}
},
/**
* Resolve the deploy-id list associated with a given job.
* Backup jobs read from dd.router (multiple deploy-ids); others from dd.cron.
*
* @param {string} jobId - Job identifier (e.g., 'dns', 'backup')
* @returns {string} Comma-separated deploy IDs
* @memberof UnderpostCron
*/
getRelatedDeployIdList(jobId) {
const deployFilePath =
jobId === 'backup' ? './engine-private/deploy/dd.router' : './engine-private/deploy/dd.cron';
if (!fs.existsSync(deployFilePath)) {
logger.warn(`Deploy file not found: ${deployFilePath}, using default`);
return fs.existsSync('./engine-private/deploy/dd.cron')
? fs.readFileSync('./engine-private/deploy/dd.cron', 'utf8').trim()
: 'dd-cron';
}
return fs.readFileSync(deployFilePath, 'utf8').trim();
},
/**
* Get the available cron job handlers.
* Each handler should have a callback function that executes the job logic.
* @memberof UnderpostCron
* @returns {Object} Available cron job handlers
*/
get JOB() {
return UnderpostCron.JOB;
},
/**
* Get the list of available job IDs.
* This is derived from the keys of the JOB object.
* @memberof UnderpostCron
* @returns {string[]} List of available job IDs
*/
getJobsIDs() {
return Object.keys(UnderpostCron.JOB);
},
};
}
export default UnderpostCron;
export { cronJobYamlFactory, resolveDeployId };