underpost 3.2.80 → 3.3.0

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 (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CRI (Container Runtime Interface) endpoint resolution shared by every layer
3
+ * that shells out to `crictl`: the cluster CLI, image management, and the
4
+ * database bootstraps. Lives under `src/server` so the db layer can import it
5
+ * without depending on the CLI god-object.
6
+ * @module src/server/cri.js
7
+ * @namespace CriEndpoint
8
+ */
9
+
10
+ import { shellExec } from './process.js';
11
+
12
+ const CRIO_SOCKET_PATH = '/var/run/crio/crio.sock';
13
+
14
+ /**
15
+ * @constant CRI_SOCKETS
16
+ * @description The CRI endpoints this platform ever targets.
17
+ * @memberof CriEndpoint
18
+ */
19
+ const CRI_SOCKETS = {
20
+ crio: `unix://${CRIO_SOCKET_PATH}`,
21
+ containerd: 'unix:///run/containerd/containerd.sock',
22
+ k3s: 'unix:///run/k3s/containerd/containerd.sock',
23
+ };
24
+
25
+ /**
26
+ * @method resolveCriSocket
27
+ * @description Resolves the CRI endpoint a `crictl` or `kubeadm` call should
28
+ * target. K3s runs its own embedded containerd; a kubeadm host uses CRI-O only
29
+ * while that socket actually exists, otherwise the host-level containerd.
30
+ *
31
+ * Detection is by socket presence, never by configuration, so a host whose
32
+ * CRI-O install was removed or stopped still resolves to a runtime that answers.
33
+ * @param {object} [options] - Resolution inputs.
34
+ * @param {boolean} [options.k3s=false] - Whether the cluster is K3s-based.
35
+ * @param {string} [options.criSocket] - Explicit endpoint override (highest precedence).
36
+ * @returns {string} CRI endpoint URI.
37
+ * @memberof CriEndpoint
38
+ */
39
+ const resolveCriSocket = (options = {}) => {
40
+ if (options?.criSocket) return options.criSocket;
41
+ if (options?.k3s) return CRI_SOCKETS.k3s;
42
+ const runtime = shellExec(`test -S ${CRIO_SOCKET_PATH} && echo crio || echo containerd`, {
43
+ stdout: true,
44
+ silent: true,
45
+ }).trim();
46
+ return runtime === 'crio' ? CRI_SOCKETS.crio : CRI_SOCKETS.containerd;
47
+ };
48
+
49
+ /**
50
+ * @method crictlCommandFactory
51
+ * @description Builds a `crictl` invocation pinned to the live CRI endpoint.
52
+ *
53
+ * Both `--runtime-endpoint` and `--image-endpoint` are passed. crictl resolves
54
+ * the two independently, and `run install-crio` writes both into
55
+ * `/etc/crictl.yaml` pointing at CRI-O; overriding only the runtime leaves image
56
+ * operations (`pull`, `rmi`, `images`) validating a `crio.sock` that no longer
57
+ * exists, which fails with "validate CRI v1 image API for endpoint".
58
+ *
59
+ * crictl is not on sudo's secure_path, hence the explicit PATH.
60
+ * @param {string} args - crictl subcommand and arguments (e.g. `pull mongo:latest`).
61
+ * @param {object} [options] - Forwarded to {@link CriEndpoint.resolveCriSocket}.
62
+ * @returns {string} Full shell command.
63
+ * @memberof CriEndpoint
64
+ */
65
+ const crictlCommandFactory = (args, options = {}) => {
66
+ const socket = resolveCriSocket(options);
67
+ return `sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl --runtime-endpoint ${socket} --image-endpoint ${socket} ${args}`;
68
+ };
69
+
70
+ export { CRI_SOCKETS, resolveCriSocket, crictlCommandFactory };
@@ -7,8 +7,9 @@
7
7
  import { loggerFactory } from './logger.js';
8
8
  import { shellExec } from './process.js';
9
9
  import fs from 'fs-extra';
10
+ import dotenv from 'dotenv';
10
11
  import Underpost from '../index.js';
11
- import { getUnderpostRootPath, loadCronDeployEnv } from './conf.js';
12
+ import { getUnderpostRootPath } from './environment.js';
12
13
 
13
14
  const logger = loggerFactory(import.meta);
14
15
 
@@ -17,6 +18,45 @@ const enginePath = '/home/dd/engine';
17
18
  const cronVolumeName = 'underpost-cron-container-volume';
18
19
  const shareEnvVolumeName = 'underpost-share-env';
19
20
  const underpostContainerEnvDir = '/usr/lib/node_modules/underpost';
21
+ const DEFAULT_CRON_ID = 'dd-cron';
22
+
23
+ /**
24
+ * Resolves the deploy ID stored in `engine-private/deploy/dd.cron`.
25
+ * @returns {string|null}
26
+ * @memberof UnderpostCron
27
+ */
28
+ const cronDeployIdResolve = () => {
29
+ const path = './engine-private/deploy/dd.cron';
30
+ if (!fs.existsSync(path)) return null;
31
+ return fs.readFileSync(path, 'utf8').trim() || null;
32
+ };
33
+
34
+ /**
35
+ * Loads cron and router deployment environment files into `process.env`.
36
+ * @returns {void}
37
+ * @memberof UnderpostCron
38
+ */
39
+ const loadCronDeployEnv = () => {
40
+ const envName = process.env.NODE_ENV || 'production';
41
+ const cronDeployId = cronDeployIdResolve();
42
+
43
+ if (cronDeployId) {
44
+ const path = `./engine-private/conf/${cronDeployId}/.env.${envName}`;
45
+ if (fs.existsSync(path)) process.env = { ...process.env, ...dotenv.parse(fs.readFileSync(path, 'utf8')) };
46
+ }
47
+
48
+ const routerPath = './engine-private/deploy/dd.router';
49
+ if (!fs.existsSync(routerPath)) return;
50
+ for (const deployId of fs.readFileSync(routerPath, 'utf8').trim().split(',')) {
51
+ const id = deployId.trim();
52
+ const path = `./engine-private/conf/${id}/.env.${envName}`;
53
+ if (!id || !fs.existsSync(path)) continue;
54
+ const env = dotenv.parse(fs.readFileSync(path, 'utf8'));
55
+ for (const [key, value] of Object.entries(env)) {
56
+ if (!(key in process.env)) process.env[key] = value;
57
+ }
58
+ }
59
+ };
20
60
 
21
61
  /**
22
62
  * Generates a Kubernetes CronJob YAML manifest string.
@@ -36,6 +76,8 @@ const underpostContainerEnvDir = '/usr/lib/node_modules/underpost';
36
76
  * @param {boolean} [params.k3s=false] - Pass --k3s flag to the cron command inside the container
37
77
  * @param {boolean} [params.kind=false] - Pass --kind flag to the cron command inside the container
38
78
  * @param {boolean} [params.kubeadm=false] - Pass --kubeadm flag to the cron command inside the container
79
+ * @param {string} [params.nodeName] - Pin the Job pod to this node via a `kubernetes.io/hostname` nodeSelector.
80
+ * Placement is a manifest concern only, so it is never forwarded to the cron command inside the container.
39
81
  * @returns {string} Kubernetes CronJob YAML manifest
40
82
  * @memberof UnderpostCron
41
83
  */
@@ -54,6 +96,7 @@ const cronJobYamlFactory = ({
54
96
  k3s = false,
55
97
  kind = false,
56
98
  kubeadm = false,
99
+ nodeName = '',
57
100
  }) => {
58
101
  const containerImage = image || `underpost/underpost-engine:${Underpost.version}`;
59
102
 
@@ -64,9 +107,11 @@ const cronJobYamlFactory = ({
64
107
  .replace(/^-|-$/g, '')
65
108
  .substring(0, 52);
66
109
 
110
+ const cronDeployId = cronDeployIdResolve();
111
+
67
112
  const cronBin = 'node bin'; // dev ? 'node bin' : 'underpost';
68
113
  const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${k3s ? '--k3s ' : ''}${kind ? '--kind ' : ''}${kubeadm ? '--kubeadm ' : ''}`;
69
- const commands = [`cd ${enginePath}`]; // `node bin run secret`
114
+ const commands = [`cd ${enginePath}`, `node bin env ${cronDeployId} ${dev ? `development` : `production`}`]; // `node bin run secret`
70
115
  if (cmd) commands.push(cmd);
71
116
  commands.push(`${cronBin} cron ${deployList} ${jobList} ${flags}`);
72
117
  const fullCommand = commands.join(' &&\n ');
@@ -87,6 +132,10 @@ spec:
87
132
  failedJobsHistoryLimit: 1
88
133
  suspend: ${suspend}
89
134
  jobTemplate:
135
+ metadata:
136
+ labels:
137
+ app: ${sanitizedName}
138
+ managed-by: underpost
90
139
  spec:
91
140
  template:
92
141
  metadata:
@@ -94,7 +143,13 @@ spec:
94
143
  app: ${sanitizedName}
95
144
  managed-by: underpost
96
145
  spec:
97
- containers:
146
+ ${
147
+ nodeName
148
+ ? ` nodeSelector:
149
+ kubernetes.io/hostname: ${nodeName}
150
+ `
151
+ : ''
152
+ } containers:
98
153
  - name: ${sanitizedName}
99
154
  image: ${containerImage}
100
155
  command:
@@ -138,20 +193,90 @@ const syncEngineToKindWorker = () => {
138
193
 
139
194
  /**
140
195
  * Resolves the deploy-id to use for cron job generation.
141
- * When deployId is provided directly, uses it. Otherwise reads from dd.cron file.
196
+ * Uses the explicit value or the deploy ID stored in `dd.cron`.
142
197
  *
143
198
  * @param {string} [deployId] - Explicit deploy-id override
144
199
  * @memberof UnderpostCron
145
200
  * @returns {string|null} Resolved deploy-id or null if not found
146
201
  */
147
- const resolveDeployId = (deployId) => {
148
- if (deployId) return deployId;
202
+ const resolveDeployId = (deployId) => deployId || cronDeployIdResolve();
149
203
 
150
- const cronDeployFilePath = './engine-private/deploy/dd.cron';
151
- if (!fs.existsSync(cronDeployFilePath)) {
152
- return null;
153
- }
154
- return fs.readFileSync(cronDeployFilePath, 'utf8').trim();
204
+ /**
205
+ * Parses a comma-separated CLI list into a trimmed, non-empty array.
206
+ *
207
+ * @param {string|string[]} [value] - Raw CLI value
208
+ * @memberof UnderpostCron
209
+ * @returns {string[]} Parsed entries
210
+ */
211
+ const parseList = (value) => {
212
+ if (Array.isArray(value)) return value.map((entry) => `${entry}`.trim()).filter(Boolean);
213
+ if (typeof value !== 'string') return [];
214
+ return value
215
+ .split(',')
216
+ .map((entry) => entry.trim())
217
+ .filter(Boolean);
218
+ };
219
+
220
+ /**
221
+ * Normalizes and validates a `--node-name` value before it reaches a manifest or a shell.
222
+ *
223
+ * @param {string} [nodeName] - Raw CLI value
224
+ * @memberof UnderpostCron
225
+ * @returns {string} Trimmed node name, or '' when unset
226
+ * @throws {Error} When the value is not a valid Kubernetes node name
227
+ */
228
+ const resolveNodeName = (nodeName) => {
229
+ const node = `${nodeName || ''}`.trim();
230
+ if (node && !/^[a-zA-Z0-9._-]+$/.test(node)) throw new Error(`Invalid Kubernetes node name: ${node}`);
231
+ return node;
232
+ };
233
+
234
+ /**
235
+ * Checks whether a node is registered on the cluster.
236
+ *
237
+ * @param {string} nodeName - Node name
238
+ * @memberof UnderpostCron
239
+ * @returns {boolean} True when the node exists
240
+ */
241
+ const nodeExists = (nodeName) => {
242
+ const stdout = shellExec(`kubectl get node ${nodeName} -o name`, {
243
+ silent: true,
244
+ stdout: true,
245
+ silentOnError: true,
246
+ disableLog: true,
247
+ });
248
+ return `${stdout || ''}`.trim().length > 0;
249
+ };
250
+
251
+ /**
252
+ * Checks whether a CronJob is already published on the cluster.
253
+ *
254
+ * @param {string} cronJobName - Sanitized CronJob name
255
+ * @param {string} namespace - Kubernetes namespace
256
+ * @memberof UnderpostCron
257
+ * @returns {boolean} True when the CronJob exists
258
+ */
259
+ const cronJobExists = (cronJobName, namespace) => {
260
+ const stdout = shellExec(`kubectl get cronjob ${cronJobName} -n ${namespace} --ignore-not-found -o name`, {
261
+ silent: true,
262
+ stdout: true,
263
+ silentOnError: true,
264
+ disableLog: true,
265
+ });
266
+ return `${stdout || ''}`.trim().length > 0;
267
+ };
268
+
269
+ /**
270
+ * Resolves the manifest owner deploy-id from the `deploy-list` positional argument.
271
+ * The `default` sentinel means "not provided", deferring to the dd.cron file.
272
+ *
273
+ * @param {string} [deployList] - Comma-separated deploy IDs from the CLI
274
+ * @memberof UnderpostCron
275
+ * @returns {string|undefined} Owner deploy-id, or undefined when unspecified
276
+ */
277
+ const deployIdFromList = (deployList) => {
278
+ const [deployId] = parseList(deployList);
279
+ return !deployId || deployId === 'default' ? undefined : deployId;
155
280
  };
156
281
 
157
282
  /**
@@ -165,6 +290,7 @@ class UnderpostCron {
165
290
  return {
166
291
  dns: Underpost.dns,
167
292
  backup: Underpost.backup,
293
+ vultr: Underpost.vultr,
168
294
  };
169
295
  }
170
296
 
@@ -172,8 +298,12 @@ class UnderpostCron {
172
298
  /**
173
299
  * CLI entry point for the `underpost cron` command.
174
300
  *
175
- * @param {string} deployList - Comma-separated deploy IDs
176
- * @param {string} jobList - Comma-separated job IDs
301
+ * Manifest modes (`--setup-start`, `--generate-k8s-cronjobs`, `--apply`, `--create-job-now`)
302
+ * never run job callbacks in this process: they write and publish manifests, and hand the
303
+ * work to the cluster. All of them are scoped to `job-list` when given.
304
+ *
305
+ * @param {string} deployList - Comma-separated deploy IDs; in manifest modes its first entry is the manifest owner deploy-id
306
+ * @param {string} jobList - Comma-separated job IDs; in manifest modes it restricts which conf.cron.json jobs are generated
177
307
  * @param {Object} options - CLI flags
178
308
  * @param {boolean} [options.generateK8sCronjobs] - Generate K8s CronJob YAML manifests
179
309
  * @param {boolean} [options.apply] - Apply manifests to the cluster
@@ -182,32 +312,44 @@ class UnderpostCron {
182
312
  * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
183
313
  * @param {string} [options.namespace] - Kubernetes namespace
184
314
  * @param {string} [options.image] - Custom container image
185
- * @param {string} [options.setupStart] - Deploy-id to setup: updates its package.json start and generates+applies cron jobs
315
+ * @param {boolean} [options.setupStart] - Update the deploy-id package.json start script and generate+apply its cron jobs
186
316
  * @param {boolean} [options.k3s] - Use k3s cluster context (apply directly on host)
187
317
  * @param {boolean} [options.kind] - Use kind cluster context (apply via kind-worker container)
188
318
  * @param {boolean} [options.kubeadm] - Use kubeadm cluster context (apply directly on host)
189
319
  * @param {boolean} [options.dryRun] - Preview cron jobs without executing them
190
320
  * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob (requires --apply)
321
+ * @param {string} [options.nodeName] - Pin generated CronJob pods to this node (manifest modes only)
191
322
  * @memberof UnderpostCron
192
323
  */
193
- callback: async function (
194
- deployList = 'default',
195
- jobList = Object.keys(Underpost.cron.JOB).join(','),
196
- options = {},
197
- ) {
324
+ callback: async function (deployList, jobList, options = {}) {
198
325
  loadCronDeployEnv();
199
- if (options.setupStart) return await Underpost.cron.setupDeployStart(options.setupStart, options);
200
326
 
201
- if (options.generateK8sCronjobs) return await Underpost.cron.generateK8sCronJobs(options);
327
+ const jobFilter = parseList(jobList);
328
+
329
+ if (options.setupStart) return await Underpost.cron.setupDeployStart(deployList, { ...options, jobFilter });
330
+
331
+ if (options.generateK8sCronjobs || options.apply || options.createJobNow)
332
+ return await Underpost.cron.generateK8sCronJobs({
333
+ ...options,
334
+ deployId: deployIdFromList(deployList),
335
+ jobFilter,
336
+ });
337
+
338
+ const resolvedDeployList = deployList || 'default';
339
+ const resolvedJobList = jobFilter.length > 0 ? jobFilter : Object.keys(Underpost.cron.JOB);
340
+
341
+ if (options.nodeName)
342
+ logger.warn(`--node-name is a manifest placement flag and has no effect on direct execution`, {
343
+ nodeName: options.nodeName,
344
+ });
202
345
 
203
- for (const _jobId of jobList.split(',')) {
204
- const jobId = _jobId.trim();
346
+ for (const jobId of resolvedJobList) {
205
347
  if (Underpost.cron.JOB[jobId]) {
206
348
  if (options.dryRun) {
207
- logger.info(`[dry-run] Would execute cron job`, { jobId, deployList, options });
349
+ logger.info(`[dry-run] Would execute cron job`, { jobId, deployList: resolvedDeployList, options });
208
350
  } else {
209
- logger.info(`Executing cron job`, { jobId, deployList, options });
210
- await Underpost.cron.JOB[jobId].callback(deployList, options);
351
+ logger.info(`Executing cron job`, { jobId, deployList: resolvedDeployList, options });
352
+ await Underpost.cron.JOB[jobId].callback(resolvedDeployList, options);
211
353
  }
212
354
  } else {
213
355
  logger.warn(`Unknown cron job: ${jobId}`);
@@ -218,14 +360,16 @@ class UnderpostCron {
218
360
  /**
219
361
  * Update the package.json start script for the given deploy-id and generate+apply its K8s CronJob manifests.
220
362
  *
221
- * @param {string} deployId - The deploy-id whose package.json will be updated
363
+ * @param {string} [deployList] - Comma-separated deploy IDs; its first entry is the deploy-id whose package.json is updated. Falls back to the dd.cron file
222
364
  * @param {Object} [options] - Additional options forwarded to generateK8sCronJobs
365
+ * @param {string[]} [options.jobFilter] - Restrict the setup to these job IDs
223
366
  * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob
224
367
  * @param {boolean} [options.dryRun] - Pass --dry-run=client to kubectl commands
225
368
  * @param {boolean} [options.apply] - Whether to apply generated manifests to the cluster
226
369
  * @param {boolean} [options.git] - Pass --git flag to cron CLI commands
227
370
  * @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
228
371
  * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
372
+ * @param {string} [options.nodeName] - Pin every generated CronJob's pod to this node
229
373
  * @param {string} [options.namespace] - Kubernetes namespace for the CronJobs
230
374
  * @param {string} [options.image] - Custom container image override for the CronJobs
231
375
  * @param {boolean} [options.k3s] - k3s cluster context (apply directly on host)
@@ -233,8 +377,18 @@ class UnderpostCron {
233
377
  * @param {boolean} [options.kubeadm] - kubeadm cluster context (apply directly on host)
234
378
  * @memberof UnderpostCron
235
379
  */
236
- setupDeployStart: async function (deployId, options = {}) {
237
- if (!deployId || deployId === true) deployId = resolveDeployId();
380
+ setupDeployStart: async function (deployList, options = {}) {
381
+ // Validated up front: an invalid node name must not leave a rewritten package.json behind.
382
+ const nodeName = resolveNodeName(options.nodeName);
383
+ const requestedDeployId = deployIdFromList(deployList);
384
+ const deployId = resolveDeployId(requestedDeployId);
385
+ if (!deployId) {
386
+ logger.warn(
387
+ 'Could not resolve deploy-id. Provide it as the deploy-list argument or create engine-private/deploy/dd.cron',
388
+ );
389
+ return;
390
+ }
391
+ if (!requestedDeployId) logger.info(`Resolved cron deploy-id from dd.cron`, { deployId });
238
392
  const confDir = `./engine-private/conf/${deployId}`;
239
393
  const packageJsonPath = `${confDir}/package.json`;
240
394
  const confCronPath = `${confDir}/conf.cron.json`;
@@ -251,17 +405,23 @@ class UnderpostCron {
251
405
  return;
252
406
  }
253
407
 
254
- const hasEnabledJobs = Object.values(confCron.jobs).some((job) => job.enabled !== false);
255
- if (!hasEnabledJobs) {
256
- logger.warn(`No enabled cron jobs for deploy-id: ${deployId}`);
408
+ const jobFilter = parseList(options.jobFilter);
409
+ const enabledJobs = Object.keys(confCron.jobs).filter(
410
+ (job) => confCron.jobs[job].enabled !== false && (jobFilter.length === 0 || jobFilter.includes(job)),
411
+ );
412
+ if (enabledJobs.length === 0) {
413
+ logger.warn(
414
+ `No enabled cron jobs for deploy-id: ${deployId}`,
415
+ jobFilter.length > 0 ? { jobFilter } : undefined,
416
+ );
257
417
  return;
258
418
  }
259
419
 
260
- // Update package.json start script
420
+ // Start script only references manifests generateK8sCronJobs actually writes
261
421
  if (fs.existsSync(packageJsonPath)) {
262
422
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
263
423
  let startCommand = 'echo "Starting cron jobs..."';
264
- for (const job of Object.keys(confCron.jobs))
424
+ for (const job of enabledJobs)
265
425
  startCommand += ` && kubectl apply -f ./manifests/cronjobs/${deployId}/${deployId}-${job}.yaml`;
266
426
  if (!packageJson.scripts) packageJson.scripts = {};
267
427
  packageJson.scripts.start = startCommand;
@@ -274,6 +434,8 @@ class UnderpostCron {
274
434
 
275
435
  await Underpost.cron.generateK8sCronJobs({
276
436
  deployId,
437
+ jobFilter,
438
+ nodeName,
277
439
  namespace: options.namespace,
278
440
  image: options.image,
279
441
  apply: options.apply,
@@ -281,7 +443,7 @@ class UnderpostCron {
281
443
  git: !!options.git,
282
444
  dev: !!options.dev,
283
445
  kubeadm: !!options.kubeadm,
284
- cmd: options.cmd || `node bin env ${deployId} production`,
446
+ cmd: options.cmd,
285
447
  k3s: !!options.k3s,
286
448
  kind: !!options.kind,
287
449
  dryRun: !!options.dryRun,
@@ -295,6 +457,7 @@ class UnderpostCron {
295
457
  *
296
458
  * @param {Object} options
297
459
  * @param {string} [options.deployId] - Explicit deploy-id (overrides dd.cron file lookup)
460
+ * @param {string|string[]} [options.jobFilter] - Restrict generation/apply to these job IDs (empty means all)
298
461
  * @param {boolean} [options.git=false] - Pass --git flag to cron CLI commands
299
462
  * @param {boolean} [options.dev=false] - Use local ./ base path instead of global underpost
300
463
  * @param {string} [options.cmd] - Optional pre-script commands
@@ -306,15 +469,17 @@ class UnderpostCron {
306
469
  * @param {boolean} [options.kubeadm=false] - kubeadm cluster context (apply directly on host)
307
470
  * @param {boolean} [options.createJobNow=false] - After applying, create a Job from each CronJob immediately
308
471
  * @param {boolean} [options.dryRun=false] - Pass --dry-run=client to kubectl commands
472
+ * @param {string} [options.nodeName] - Pin every generated CronJob's pod to this node
309
473
  * @memberof UnderpostCron
310
474
  */
311
475
  generateK8sCronJobs: async function (options = {}) {
312
476
  const namespace = options.namespace || 'default';
477
+ const nodeName = resolveNodeName(options.nodeName);
313
478
  const jobDeployId = resolveDeployId(options.deployId);
314
479
 
315
480
  if (!jobDeployId) {
316
481
  logger.warn(
317
- 'Could not resolve deploy-id. Provide --setup-start <deploy-id> or create engine-private/deploy/dd.cron',
482
+ 'Could not resolve deploy-id. Provide it as the deploy-list argument or create engine-private/deploy/dd.cron',
318
483
  );
319
484
  return;
320
485
  }
@@ -333,12 +498,26 @@ class UnderpostCron {
333
498
  return;
334
499
  }
335
500
 
501
+ const jobFilter = parseList(options.jobFilter);
502
+ const targetJobs = Object.keys(confCronConfig.jobs).filter(
503
+ (job) => jobFilter.length === 0 || jobFilter.includes(job),
504
+ );
505
+
506
+ if (targetJobs.length === 0) {
507
+ logger.warn(`No cron jobs matched the requested job list`, {
508
+ deployId: jobDeployId,
509
+ jobFilter,
510
+ available: Object.keys(confCronConfig.jobs),
511
+ });
512
+ return;
513
+ }
514
+
336
515
  const outputDir = `./manifests/cronjobs/${jobDeployId}`;
337
516
  fs.mkdirSync(outputDir, { recursive: true });
338
517
 
339
518
  const generatedFiles = [];
340
519
 
341
- for (const job of Object.keys(confCronConfig.jobs)) {
520
+ for (const job of targetJobs) {
342
521
  const jobConfig = confCronConfig.jobs[job];
343
522
 
344
523
  if (jobConfig.enabled === false) {
@@ -365,18 +544,29 @@ class UnderpostCron {
365
544
  k3s: !!options.k3s,
366
545
  kind: !!options.kind,
367
546
  kubeadm: !!options.kubeadm,
547
+ nodeName,
368
548
  });
369
549
 
370
550
  const yamlFilePath = `${outputDir}/${cronJobName}.yaml`;
371
551
  fs.writeFileSync(yamlFilePath, yamlContent, 'utf8');
372
552
  generatedFiles.push(yamlFilePath);
373
553
 
374
- logger.info(`Generated CronJob manifest: ${yamlFilePath}`, { job, expression, namespace });
554
+ logger.info(`Generated CronJob manifest: ${yamlFilePath}`, {
555
+ job,
556
+ expression,
557
+ namespace,
558
+ ...(nodeName ? { nodeName } : {}),
559
+ });
375
560
  }
376
561
 
377
562
  if (options.apply) {
563
+ // A nodeSelector naming a node that is not registered leaves every Job Pending at its
564
+ // next fire, silently. Warn rather than throw: the node may join before the schedule.
565
+ if (nodeName && !nodeExists(nodeName))
566
+ logger.warn(`Target node not found on the cluster; pods will stay Pending until it joins`, { nodeName });
567
+
378
568
  // Delete existing CronJobs before applying new ones
379
- for (const job of Object.keys(confCronConfig.jobs)) {
569
+ for (const job of targetJobs) {
380
570
  const cronJobName = `${jobDeployId}-${job}`;
381
571
  shellExec(`kubectl delete cronjob ${cronJobName} --namespace=${namespace} --ignore-not-found`);
382
572
  }
@@ -406,9 +596,10 @@ class UnderpostCron {
406
596
  } else {
407
597
  logger.info(`Manifests generated in ${outputDir}. Use --apply to deploy to the cluster.`);
408
598
  }
409
- // Create an immediate Job from each CronJob if requested
599
+ // Create an immediate Job from each CronJob if requested. Runs after --apply so the
600
+ // Job is always cloned from the manifest this invocation just published.
410
601
  if (options.createJobNow) {
411
- for (const job of Object.keys(confCronConfig.jobs)) {
602
+ for (const job of targetJobs) {
412
603
  const jobConfig = confCronConfig.jobs[job];
413
604
  if (jobConfig.enabled === false) continue;
414
605
 
@@ -419,6 +610,15 @@ class UnderpostCron {
419
610
  .replace(/^-|-$/g, '')
420
611
  .substring(0, 52);
421
612
 
613
+ if (!cronJobExists(cronJobName, namespace)) {
614
+ logger.warn(`CronJob not found on the cluster, skipping immediate Job`, {
615
+ cronJobName,
616
+ namespace,
617
+ hint: 'add --apply to publish the manifest first',
618
+ });
619
+ continue;
620
+ }
621
+
422
622
  const immediateJobName = `${cronJobName}-now-${Date.now()}`.substring(0, 63);
423
623
  logger.info(`Creating immediate Job from CronJob: ${cronJobName}`, { jobName: immediateJobName });
424
624
  shellExec(`kubectl create job ${immediateJobName} --from=cronjob/${cronJobName} -n ${namespace}`);
@@ -436,17 +636,15 @@ class UnderpostCron {
436
636
  * @memberof UnderpostCron
437
637
  */
438
638
  getRelatedDeployIdList(jobId) {
439
- const deployFilePath =
440
- jobId === 'backup' ? './engine-private/deploy/dd.router' : './engine-private/deploy/dd.cron';
441
-
442
- if (!fs.existsSync(deployFilePath)) {
443
- logger.warn(`Deploy file not found: ${deployFilePath}, using default`);
444
- return fs.existsSync('./engine-private/deploy/dd.cron')
445
- ? fs.readFileSync('./engine-private/deploy/dd.cron', 'utf8').trim()
446
- : 'dd-cron';
639
+ if (jobId === 'backup') {
640
+ const routerFilePath = './engine-private/deploy/dd.router';
641
+ if (fs.existsSync(routerFilePath)) return fs.readFileSync(routerFilePath, 'utf8').trim();
642
+ logger.warn(`Deploy file not found: ${routerFilePath}, falling back to the cron deploy-id`);
447
643
  }
448
644
 
449
- return fs.readFileSync(deployFilePath, 'utf8').trim();
645
+ const cronDeployId = cronDeployIdResolve();
646
+ if (!cronDeployId) logger.warn(`Cron deploy-id not resolved, using default`, { jobId, default: DEFAULT_CRON_ID });
647
+ return cronDeployId || DEFAULT_CRON_ID;
450
648
  },
451
649
 
452
650
  /**
@@ -473,4 +671,4 @@ class UnderpostCron {
473
671
 
474
672
  export default UnderpostCron;
475
673
 
476
- export { cronJobYamlFactory, resolveDeployId };
674
+ export { cronDeployIdResolve, cronJobYamlFactory, loadCronDeployEnv, resolveDeployId };