@underpostnet/underpost 2.99.5 → 2.99.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/workflows/ghpkg.ci.yml +10 -25
  2. package/.github/workflows/npmpkg.ci.yml +13 -2
  3. package/CHANGELOG.md +496 -0
  4. package/README.md +4 -4
  5. package/baremetal/commission-workflows.json +43 -6
  6. package/bin/deploy.js +13 -0
  7. package/cli.md +84 -42
  8. package/examples/static-page/README.md +80 -13
  9. package/jsdoc.json +26 -5
  10. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +47 -0
  11. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +47 -0
  12. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  13. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  14. package/package.json +2 -4
  15. package/scripts/maas-setup.sh +13 -9
  16. package/scripts/rocky-kickstart.sh +294 -0
  17. package/src/cli/baremetal.js +237 -555
  18. package/src/cli/cloud-init.js +27 -45
  19. package/src/cli/index.js +52 -6
  20. package/src/cli/kickstart.js +149 -0
  21. package/src/cli/repository.js +166 -13
  22. package/src/cli/run.js +26 -19
  23. package/src/cli/ssh.js +1 -1
  24. package/src/cli/static.js +27 -1
  25. package/src/cli/system.js +332 -0
  26. package/src/client/components/core/Docs.js +22 -3
  27. package/src/db/DataBaseProvider.js +3 -3
  28. package/src/db/mariadb/MariaDB.js +3 -3
  29. package/src/db/mongo/MongooseDB.js +3 -3
  30. package/src/index.js +28 -5
  31. package/src/mailer/EmailRender.js +3 -3
  32. package/src/mailer/MailerProvider.js +4 -4
  33. package/src/server/backup.js +23 -5
  34. package/src/server/client-build-docs.js +29 -3
  35. package/src/server/conf.js +6 -27
  36. package/src/server/cron.js +354 -135
  37. package/src/server/dns.js +2 -0
@@ -4,76 +4,206 @@
4
4
  * @namespace UnderpostCron
5
5
  */
6
6
 
7
- import { Cmd } from './conf.js';
8
7
  import { loggerFactory } from './logger.js';
9
8
  import { shellExec } from './process.js';
10
9
  import fs from 'fs-extra';
11
10
  import Underpost from '../index.js';
11
+ import { getUnderpostRootPath } from './conf.js';
12
12
 
13
13
  const logger = loggerFactory(import.meta);
14
14
 
15
+ const volumeHostPath = '/home/dd';
16
+ const enginePath = '/home/dd/engine';
17
+ const cronVolumeName = 'underpost-cron-container-volume';
18
+ const shareEnvVolumeName = 'underpost-share-env';
19
+ const underpostContainerEnvPath = '/usr/lib/node_modules/underpost/.env';
20
+
21
+ /**
22
+ * Generates a Kubernetes CronJob YAML manifest string.
23
+ *
24
+ * @param {Object} params - CronJob parameters
25
+ * @param {string} params.name - CronJob name (max 52 chars, sanitized to DNS subdomain)
26
+ * @param {string} params.expression - Cron schedule expression (e.g., '0 0 * * *')
27
+ * @param {string} params.deployList - Comma-separated deploy IDs for the cron CLI
28
+ * @param {string} params.jobList - Comma-separated job IDs (e.g., 'dns', 'backup')
29
+ * @param {string} [params.image] - Container image (defaults to underpost/underpost-engine:<version>)
30
+ * @param {string} [params.namespace='default'] - Kubernetes namespace
31
+ * @param {boolean} [params.git=false] - Pass --git flag to cron CLI
32
+ * @param {boolean} [params.dev=false] - Use local ./ base path instead of global underpost installation
33
+ * @param {string} [params.cmd] - Optional pre-script commands to run before cron execution
34
+ * @param {boolean} [params.suspend=false] - Whether the CronJob is suspended
35
+ * @param {boolean} [params.dryRun=false] - Pass --dry-run flag to the cron command inside the container
36
+ * @param {boolean} [params.ssh=false] - Execute backup commands via SSH on the remote node
37
+ * @returns {string} Kubernetes CronJob YAML manifest
38
+ * @memberof UnderpostCron
39
+ */
40
+ const cronJobYamlFactory = ({
41
+ name,
42
+ expression,
43
+ deployList,
44
+ jobList,
45
+ image,
46
+ namespace = 'default',
47
+ git = false,
48
+ dev = false,
49
+ cmd,
50
+ suspend = false,
51
+ dryRun = false,
52
+ ssh = false,
53
+ }) => {
54
+ const containerImage = image || `underpost/underpost-engine:${Underpost.version}`;
55
+
56
+ const sanitizedName = name
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9-]/g, '-')
59
+ .replace(/--+/g, '-')
60
+ .replace(/^-|-$/g, '')
61
+ .substring(0, 52);
62
+
63
+ const cmdPart = cmd ? `${cmd} && ` : '';
64
+ const cronBin = dev ? 'node bin' : 'underpost';
65
+ const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${ssh ? '--ssh ' : ''}`;
66
+ const cronCommand = `${cmdPart}${cronBin} cron ${flags}${deployList} ${jobList}`;
67
+
68
+ return `apiVersion: batch/v1
69
+ kind: CronJob
70
+ metadata:
71
+ name: ${sanitizedName}
72
+ namespace: ${namespace}
73
+ labels:
74
+ app: ${sanitizedName}
75
+ managed-by: underpost
76
+ spec:
77
+ schedule: "${expression}"
78
+ concurrencyPolicy: Forbid
79
+ startingDeadlineSeconds: 200
80
+ successfulJobsHistoryLimit: 3
81
+ failedJobsHistoryLimit: 1
82
+ suspend: ${suspend}
83
+ jobTemplate:
84
+ spec:
85
+ template:
86
+ metadata:
87
+ labels:
88
+ app: ${sanitizedName}
89
+ managed-by: underpost
90
+ spec:
91
+ containers:
92
+ - name: ${sanitizedName}
93
+ image: ${containerImage}
94
+ command:
95
+ - /bin/sh
96
+ - -c
97
+ - >
98
+ ${cronCommand}
99
+ volumeMounts:
100
+ - mountPath: ${enginePath}
101
+ name: ${cronVolumeName}
102
+ - mountPath: ${underpostContainerEnvPath}
103
+ name: ${shareEnvVolumeName}
104
+ subPath: .env
105
+ volumes:
106
+ - hostPath:
107
+ path: ${enginePath}
108
+ type: Directory
109
+ name: ${cronVolumeName}
110
+ - hostPath:
111
+ path: ${getUnderpostRootPath()}
112
+ type: DirectoryOrCreate
113
+ name: ${shareEnvVolumeName}
114
+ restartPolicy: OnFailure
115
+ `;
116
+ };
117
+
118
+ /**
119
+ * Syncs the engine directory into the kind-worker container node.
120
+ * Required for kind clusters where worker nodes don't share the host filesystem.
121
+ *
122
+ * @memberof UnderpostCron
123
+ */
124
+ const syncEngineToKindWorker = () => {
125
+ logger.info('Syncing engine volume to kind-worker node');
126
+ shellExec(`docker exec -i kind-worker bash -c "rm -rf ${volumeHostPath}"`);
127
+ shellExec(`docker exec -i kind-worker bash -c "mkdir -p ${volumeHostPath}"`);
128
+ shellExec(`docker cp ${volumeHostPath}/engine kind-worker:${volumeHostPath}/engine`);
129
+ shellExec(
130
+ `docker exec -i kind-worker bash -c "chown -R 1000:1000 ${volumeHostPath}; chmod -R 755 ${volumeHostPath}"`,
131
+ );
132
+ };
133
+
134
+ /**
135
+ * Resolves the deploy-id to use for cron job generation.
136
+ * When deployId is provided directly, uses it. Otherwise reads from dd.cron file.
137
+ *
138
+ * @param {string} [deployId] - Explicit deploy-id override
139
+ * @memberof UnderpostCron
140
+ * @returns {string|null} Resolved deploy-id or null if not found
141
+ */
142
+ const resolveDeployId = (deployId) => {
143
+ if (deployId) return deployId;
144
+
145
+ const cronDeployFilePath = './engine-private/deploy/dd.cron';
146
+ if (!fs.existsSync(cronDeployFilePath)) {
147
+ return null;
148
+ }
149
+ return fs.readFileSync(cronDeployFilePath, 'utf8').trim();
150
+ };
151
+
15
152
  /**
16
153
  * UnderpostCron main module methods
17
154
  * @class UnderpostCron
18
155
  * @memberof UnderpostCron
19
156
  */
20
157
  class UnderpostCron {
21
- /**
22
- * Get the JOB static member
23
- * @static
24
- * @type {Object}
25
- * @memberof UnderpostCron
26
- */
158
+ /** @returns {Object} Available cron job handlers */
27
159
  static get JOB() {
28
160
  return {
29
- /**
30
- * DNS cli API
31
- * @static
32
- * @type {Dns}
33
- * @memberof UnderpostCron
34
- */
35
161
  dns: Underpost.dns,
36
- /**
37
- * BackUp cli API
38
- * @static
39
- * @type {BackUp}
40
- * @memberof UnderpostCron
41
- */
42
162
  backup: Underpost.backup,
43
163
  };
44
164
  }
45
165
 
46
166
  static API = {
47
167
  /**
48
- * Run the cron jobs
49
- * @static
50
- * @param {String} deployList - Comma separated deploy ids
51
- * @param {String} jobList - Comma separated job ids
52
- * @param {Object} options - Options for cron execution
53
- * @return {void}
168
+ * CLI entry point for the `underpost cron` command.
169
+ *
170
+ * @param {string} deployList - Comma-separated deploy IDs
171
+ * @param {string} jobList - Comma-separated job IDs
172
+ * @param {Object} options - CLI flags
173
+ * @param {boolean} [options.generateK8sCronjobs] - Generate K8s CronJob YAML manifests
174
+ * @param {boolean} [options.apply] - Apply manifests to the cluster
175
+ * @param {boolean} [options.git] - Pass --git to job execution
176
+ * @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
177
+ * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
178
+ * @param {string} [options.namespace] - Kubernetes namespace
179
+ * @param {string} [options.image] - Custom container image
180
+ * @param {string} [options.setupStart] - Deploy-id to setup: updates its package.json start and generates+applies cron jobs
181
+ * @param {boolean} [options.k3s] - Use k3s cluster context (apply directly on host)
182
+ * @param {boolean} [options.kind] - Use kind cluster context (apply via kind-worker container)
183
+ * @param {boolean} [options.kubeadm] - Use kubeadm cluster context (apply directly on host)
184
+ * @param {boolean} [options.dryRun] - Preview cron jobs without executing them
185
+ * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob (requires --apply)
186
+ * @param {boolean} [options.ssh] - Execute backup commands via SSH on the remote node
54
187
  * @memberof UnderpostCron
55
188
  */
56
189
  callback: async function (
57
190
  deployList = 'default',
58
191
  jobList = Object.keys(Underpost.cron.JOB).join(','),
59
- options = { initPm2Cronjobs: false, git: false, updatePackageScripts: false },
192
+ options = {},
60
193
  ) {
61
- if (options.updatePackageScripts === true) {
62
- await Underpost.cron.updatePackageScripts(deployList);
63
- return;
64
- }
194
+ if (options.setupStart) return await Underpost.cron.setupDeployStart(options.setupStart, options);
65
195
 
66
- if (options.initPm2Cronjobs === true) {
67
- await Underpost.cron.initCronJobs(options);
68
- return;
69
- }
196
+ if (options.generateK8sCronjobs) return await Underpost.cron.generateK8sCronJobs(options);
70
197
 
71
- // Execute the requested jobs
72
198
  for (const _jobId of jobList.split(',')) {
73
199
  const jobId = _jobId.trim();
74
200
  if (Underpost.cron.JOB[jobId]) {
75
- logger.info(`Executing cron job: ${jobId}`);
76
- await Underpost.cron.JOB[jobId].callback(deployList, options);
201
+ if (options.dryRun) {
202
+ logger.info(`[dry-run] Would execute cron job`, { jobId, deployList, options });
203
+ } else {
204
+ logger.info(`Executing cron job`, { jobId, deployList, options });
205
+ await Underpost.cron.JOB[jobId].callback(deployList, options);
206
+ }
77
207
  } else {
78
208
  logger.warn(`Unknown cron job: ${jobId}`);
79
209
  }
@@ -81,140 +211,228 @@ class UnderpostCron {
81
211
  },
82
212
 
83
213
  /**
84
- * Initialize PM2 cron jobs from configuration
85
- * @static
86
- * @param {Object} options - Initialization options
214
+ * Update the package.json start script for the given deploy-id and generate+apply its K8s CronJob manifests.
215
+ *
216
+ * @param {string} deployId - The deploy-id whose package.json will be updated
217
+ * @param {Object} [options] - Additional options forwarded to generateK8sCronJobs
218
+ * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob
219
+ * @param {boolean} [options.dryRun] - Pass --dry-run=client to kubectl commands
220
+ * @param {boolean} [options.apply] - Whether to apply generated manifests to the cluster
221
+ * @param {boolean} [options.git] - Pass --git flag to cron CLI commands
222
+ * @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
223
+ * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
224
+ * @param {string} [options.namespace] - Kubernetes namespace for the CronJobs
225
+ * @param {string} [options.image] - Custom container image override for the CronJobs
226
+ * @param {boolean} [options.k3s] - k3s cluster context (apply directly on host)
227
+ * @param {boolean} [options.kind] - kind cluster context (apply via kind-worker container)
228
+ * @param {boolean} [options.kubeadm] - kubeadm cluster context (apply directly on host)
229
+ * @param {boolean} [options.ssh] - Execute backup commands via SSH on the remote node
87
230
  * @memberof UnderpostCron
88
231
  */
89
- initCronJobs: async function (options = { git: false }) {
90
- logger.info('Initializing PM2 cron jobs');
91
-
92
- // Read cron job deployment ID from dd.cron file (e.g., "dd-cron")
93
- const jobDeployId = fs.readFileSync('./engine-private/deploy/dd.cron', 'utf8').trim();
94
- const confCronPath = `./engine-private/conf/${jobDeployId}/conf.cron.json`;
232
+ setupDeployStart: async function (deployId, options = {}) {
233
+ if (!deployId || deployId === true) deployId = resolveDeployId();
234
+ const confDir = `./engine-private/conf/${deployId}`;
235
+ const packageJsonPath = `${confDir}/package.json`;
236
+ const confCronPath = `${confDir}/conf.cron.json`;
95
237
 
96
238
  if (!fs.existsSync(confCronPath)) {
97
- logger.warn(`Cron configuration not found: ${confCronPath}`);
239
+ logger.warn(`conf.cron.json not found for deploy-id: ${deployId}`, { path: confCronPath });
98
240
  return;
99
241
  }
100
242
 
101
- const confCronConfig = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
243
+ const confCron = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
102
244
 
103
- if (!confCronConfig.jobs || Object.keys(confCronConfig.jobs).length === 0) {
104
- logger.info('No cron jobs configured');
245
+ if (!confCron.jobs || Object.keys(confCron.jobs).length === 0) {
246
+ logger.warn(`No cron jobs configured for deploy-id: ${deployId}`);
105
247
  return;
106
248
  }
107
249
 
108
- // Delete all existing cron jobs
109
- for (const job of Object.keys(confCronConfig.jobs)) {
110
- const name = `${jobDeployId}-${job}`;
111
- logger.info(`Removing existing PM2 process: ${name}`);
112
- shellExec(Cmd.delete(name));
250
+ const hasEnabledJobs = Object.values(confCron.jobs).some((job) => job.enabled !== false);
251
+ if (!hasEnabledJobs) {
252
+ logger.warn(`No enabled cron jobs for deploy-id: ${deployId}`);
253
+ return;
113
254
  }
114
255
 
115
- // Create PM2 cron jobs for each configured job
116
- for (const job of Object.keys(confCronConfig.jobs)) {
117
- const jobConfig = confCronConfig.jobs[job];
118
-
119
- if (jobConfig.enabled === false) {
120
- logger.info(`Skipping disabled job: ${job}`);
121
- continue;
122
- }
123
-
124
- const name = `${jobDeployId}-${job}`;
125
- const deployIdList = Underpost.cron.getRelatedDeployIdList(job);
126
- const expression = jobConfig.expression || '0 0 * * *'; // Default: daily at midnight
127
- const instances = jobConfig.instances || 1; // Default: 1 instance
128
-
129
- logger.info(`Creating PM2 cron job: ${name} with expression: ${expression}, instances: ${instances}`);
130
- shellExec(Cmd.cron(deployIdList, job, name, expression, options, instances));
256
+ // Update package.json start script
257
+ if (fs.existsSync(packageJsonPath)) {
258
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
259
+ let startCommand = 'echo "Starting cron jobs..."';
260
+ for (const job of Object.keys(confCron.jobs))
261
+ startCommand += ` && kubectl apply -f ./manifests/cronjobs/${deployId}/${deployId}-${job}.yaml`;
262
+ if (!packageJson.scripts) packageJson.scripts = {};
263
+ packageJson.scripts.start = startCommand;
264
+
265
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + '\n', 'utf8');
266
+ logger.info(`Updated package.json start script for ${deployId}`, { path: packageJsonPath });
267
+ } else {
268
+ logger.warn(`package.json not found for deploy-id: ${deployId}`, { path: packageJsonPath });
131
269
  }
132
270
 
133
- logger.info('PM2 cron jobs initialization completed');
271
+ // Generate and apply cron job manifests for this deploy-id
272
+ await Underpost.cron.generateK8sCronJobs({
273
+ deployId,
274
+ namespace: options.namespace,
275
+ image: options.image,
276
+ apply: options.apply,
277
+ createJobNow: options.createJobNow,
278
+ git: true,
279
+ dev: true,
280
+ kubeadm: true,
281
+ ssh: true,
282
+ cmd: ` cd ${enginePath} && node bin env ${deployId} production`,
283
+ k3s: false,
284
+ kind: false,
285
+ dryRun: false,
286
+ });
134
287
  },
135
288
 
136
289
  /**
137
- * Update package.json start scripts for specified deploy-ids
138
- * @static
139
- * @param {String} deployList - Comma separated deploy ids
290
+ * Generate Kubernetes CronJob YAML manifests from conf.cron.json configuration.
291
+ * Each enabled job produces one CronJob YAML file under manifests/cronjobs/<deployId>/.
292
+ * With --apply the manifests are also applied to the cluster via kubectl.
293
+ *
294
+ * @param {Object} options
295
+ * @param {string} [options.deployId] - Explicit deploy-id (overrides dd.cron file lookup)
296
+ * @param {boolean} [options.git=false] - Pass --git flag to cron CLI commands
297
+ * @param {boolean} [options.dev=false] - Use local ./ base path instead of global underpost
298
+ * @param {string} [options.cmd] - Optional pre-script commands
299
+ * @param {boolean} [options.apply=false] - kubectl apply generated manifests
300
+ * @param {string} [options.namespace='default'] - Target Kubernetes namespace
301
+ * @param {string} [options.image] - Custom container image override
302
+ * @param {boolean} [options.k3s=false] - k3s cluster context (apply directly on host)
303
+ * @param {boolean} [options.kind=false] - kind cluster context (apply via kind-worker container)
304
+ * @param {boolean} [options.kubeadm=false] - kubeadm cluster context (apply directly on host)
305
+ * @param {boolean} [options.createJobNow=false] - After applying, create a Job from each CronJob immediately
306
+ * @param {boolean} [options.dryRun=false] - Pass --dry-run=client to kubectl commands
307
+ * @param {boolean} [options.ssh=false] - Execute backup commands via SSH on the remote node
140
308
  * @memberof UnderpostCron
141
309
  */
142
- updatePackageScripts: async function (deployList = 'default') {
143
- logger.info('Updating package.json start scripts for deploy-id configurations');
310
+ generateK8sCronJobs: async function (options = {}) {
311
+ const namespace = options.namespace || 'default';
312
+ const jobDeployId = resolveDeployId(options.deployId);
313
+
314
+ if (!jobDeployId) {
315
+ logger.warn(
316
+ 'Could not resolve deploy-id. Provide --setup-start <deploy-id> or create engine-private/deploy/dd.cron',
317
+ );
318
+ return;
319
+ }
320
+
321
+ const confCronPath = `./engine-private/conf/${jobDeployId}/conf.cron.json`;
144
322
 
145
- // Resolve deploy list
146
- if ((!deployList || deployList === 'dd') && fs.existsSync(`./engine-private/deploy/dd.router`)) {
147
- deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').trim();
323
+ if (!fs.existsSync(confCronPath)) {
324
+ logger.warn(`Cron configuration not found: ${confCronPath}`);
325
+ return;
148
326
  }
149
327
 
150
- const confDir = './engine-private/conf';
151
- if (!fs.existsSync(confDir)) {
152
- logger.warn(`Configuration directory not found: ${confDir}`);
328
+ const confCronConfig = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
329
+
330
+ if (!confCronConfig.jobs || Object.keys(confCronConfig.jobs).length === 0) {
331
+ logger.info('No cron jobs configured');
153
332
  return;
154
333
  }
155
334
 
156
- // Parse deploy list into array
157
- const deployIds = deployList
158
- .split(',')
159
- .map((id) => id.trim())
160
- .filter((id) => id);
335
+ const outputDir = `./manifests/cronjobs/${jobDeployId}`;
336
+ fs.mkdirSync(outputDir, { recursive: true });
161
337
 
162
- for (const deployId of deployIds) {
163
- const packageJsonPath = `${confDir}/${deployId}/package.json`;
164
- const confCronPath = `${confDir}/${deployId}/conf.cron.json`;
338
+ const generatedFiles = [];
165
339
 
166
- // Only update if both package.json and conf.cron.json exist
167
- if (!fs.existsSync(packageJsonPath)) {
168
- logger.info(`Skipping ${deployId}: package.json not found`);
169
- continue;
170
- }
340
+ for (const job of Object.keys(confCronConfig.jobs)) {
341
+ const jobConfig = confCronConfig.jobs[job];
171
342
 
172
- if (!fs.existsSync(confCronPath)) {
173
- logger.info(`Skipping ${deployId}: conf.cron.json not found`);
343
+ if (jobConfig.enabled === false) {
344
+ logger.info(`Skipping disabled job: ${job}`);
174
345
  continue;
175
346
  }
176
347
 
177
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
178
- const confCron = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
179
-
180
- // Build start script based on cron jobs configuration
181
- if (confCron.jobs && Object.keys(confCron.jobs).length > 0) {
182
- const hasEnabledJobs = Object.values(confCron.jobs).some((job) => job.enabled !== false);
348
+ const deployIdList = Underpost.cron.getRelatedDeployIdList(job);
349
+ const expression = jobConfig.expression || '0 0 * * *';
350
+ const cronJobName = `${jobDeployId}-${job}`;
351
+
352
+ const yamlContent = cronJobYamlFactory({
353
+ name: cronJobName,
354
+ expression,
355
+ deployList: deployIdList,
356
+ jobList: job,
357
+ image: options.image,
358
+ namespace,
359
+ git: !!options.git,
360
+ dev: !!options.dev,
361
+ cmd: options.cmd,
362
+ suspend: false,
363
+ dryRun: !!options.dryRun,
364
+ ssh: !!options.ssh,
365
+ });
366
+
367
+ const yamlFilePath = `${outputDir}/${cronJobName}.yaml`;
368
+ fs.writeFileSync(yamlFilePath, yamlContent, 'utf8');
369
+ generatedFiles.push(yamlFilePath);
370
+
371
+ logger.info(`Generated CronJob manifest: ${yamlFilePath}`, { job, expression, namespace });
372
+ }
183
373
 
184
- if (hasEnabledJobs) {
185
- // Update start script with PM2 cron jobs initialization
186
- const startScript = 'pm2 flush && pm2 reloadLogs && node bin cron --init-pm2-cronjobs --git';
374
+ if (options.apply) {
375
+ // Delete existing CronJobs before applying new ones
376
+ for (const job of Object.keys(confCronConfig.jobs)) {
377
+ const cronJobName = `${jobDeployId}-${job}`;
378
+ shellExec(`kubectl delete cronjob ${cronJobName} --namespace=${namespace} --ignore-not-found`);
379
+ }
187
380
 
188
- if (!packageJson.scripts) {
189
- packageJson.scripts = {};
190
- }
381
+ // Ensure default dockerhub image is loaded on the cluster when no custom image is provided
382
+ if (!options.image) {
383
+ logger.info('Ensuring default image is loaded on cluster');
384
+ Underpost.image.pullDockerHubImage({
385
+ dockerhubImage: 'underpost',
386
+ kind: !!options.kind,
387
+ k3s: !!options.k3s,
388
+ kubeadm: !!options.kubeadm,
389
+ dev: !!options.dev,
390
+ });
391
+ }
191
392
 
192
- packageJson.scripts.start = startScript;
393
+ // Sync engine volume to kind-worker node if using kind cluster
394
+ if (options.kind) {
395
+ syncEngineToKindWorker();
396
+ }
193
397
 
194
- // Write updated package.json
195
- fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + '\n', 'utf8');
196
- logger.info(`Updated package.json for ${deployId} with cron start script`);
197
- } else {
198
- logger.info(`Skipping ${deployId}: no enabled cron jobs`);
199
- }
200
- } else {
201
- logger.info(`Skipping ${deployId}: no cron jobs configured`);
398
+ for (const yamlFile of generatedFiles) {
399
+ logger.info(`Applying: ${yamlFile}`);
400
+ shellExec(`kubectl apply -f ${yamlFile}`);
202
401
  }
402
+ logger.info('All CronJob manifests applied');
403
+ } else {
404
+ logger.info(`Manifests generated in ${outputDir}. Use --apply to deploy to the cluster.`);
405
+ }
406
+ // Create an immediate Job from each CronJob if requested
407
+ if (options.createJobNow) {
408
+ for (const job of Object.keys(confCronConfig.jobs)) {
409
+ const jobConfig = confCronConfig.jobs[job];
410
+ if (jobConfig.enabled === false) continue;
411
+
412
+ const cronJobName = `${jobDeployId}-${job}`
413
+ .toLowerCase()
414
+ .replace(/[^a-z0-9-]/g, '-')
415
+ .replace(/--+/g, '-')
416
+ .replace(/^-|-$/g, '')
417
+ .substring(0, 52);
418
+
419
+ const immediateJobName = `${cronJobName}-now-${Date.now()}`.substring(0, 63);
420
+ logger.info(`Creating immediate Job from CronJob: ${cronJobName}`, { jobName: immediateJobName });
421
+ shellExec(`kubectl create job ${immediateJobName} --from=cronjob/${cronJobName} -n ${namespace}`);
422
+ }
423
+ logger.info('All immediate Jobs created');
203
424
  }
204
-
205
- logger.info('Package.json start scripts update completed');
206
425
  },
207
426
 
208
427
  /**
209
- * Get the related deploy id list for the given job id
210
- * @static
211
- * @param {String} jobId - The job id (e.g., 'dns', 'backup')
212
- * @return {String} Comma-separated list of deploy ids to process
428
+ * Resolve the deploy-id list associated with a given job.
429
+ * Backup jobs read from dd.router (multiple deploy-ids); others from dd.cron.
430
+ *
431
+ * @param {string} jobId - Job identifier (e.g., 'dns', 'backup')
432
+ * @returns {string} Comma-separated deploy IDs
213
433
  * @memberof UnderpostCron
214
434
  */
215
435
  getRelatedDeployIdList(jobId) {
216
- // Backup job uses dd.router file (contains multiple deploy-ids)
217
- // Other jobs use dd.cron file (contains single deploy-id)
218
436
  const deployFilePath =
219
437
  jobId === 'backup' ? './engine-private/deploy/dd.router' : './engine-private/deploy/dd.cron';
220
438
 
@@ -225,30 +443,31 @@ class UnderpostCron {
225
443
  : 'dd-cron';
226
444
  }
227
445
 
228
- // Return the deploy-id list from the file (may be single or comma-separated)
229
446
  return fs.readFileSync(deployFilePath, 'utf8').trim();
230
447
  },
231
448
 
232
449
  /**
233
- * Get the JOB static object
234
- * @static
235
- * @type {Object}
450
+ * Get the available cron job handlers.
451
+ * Each handler should have a callback function that executes the job logic.
236
452
  * @memberof UnderpostCron
453
+ * @returns {Object} Available cron job handlers
237
454
  */
238
455
  get JOB() {
239
456
  return UnderpostCron.JOB;
240
457
  },
241
458
 
242
459
  /**
243
- * Get the list of available job IDs
244
- * @static
245
- * @return {Array<String>} List of job IDs
460
+ * Get the list of available job IDs.
461
+ * This is derived from the keys of the JOB object.
246
462
  * @memberof UnderpostCron
463
+ * @returns {string[]} List of available job IDs
247
464
  */
248
- getJobsIDs: function () {
465
+ getJobsIDs() {
249
466
  return Object.keys(UnderpostCron.JOB);
250
467
  },
251
468
  };
252
469
  }
253
470
 
254
471
  export default UnderpostCron;
472
+
473
+ export { cronJobYamlFactory, resolveDeployId };
package/src/server/dns.js CHANGED
@@ -13,6 +13,8 @@ import dns from 'node:dns';
13
13
  import os from 'node:os';
14
14
  import { shellExec, pbcopy } from './process.js';
15
15
  import Underpost from '../index.js';
16
+ import { writeEnv } from './conf.js';
17
+ import { resolveDeployId } from './cron.js';
16
18
 
17
19
  dotenv.config();
18
20