@underpostnet/underpost 2.90.4 → 2.95.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 (41) hide show
  1. package/.github/workflows/pwa-microservices-template-page.cd.yml +5 -4
  2. package/.github/workflows/release.cd.yml +7 -7
  3. package/README.md +7 -8
  4. package/bin/build.js +6 -1
  5. package/bin/deploy.js +2 -196
  6. package/cli.md +154 -80
  7. package/manifests/deployment/dd-default-development/deployment.yaml +4 -4
  8. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  9. package/package.json +1 -1
  10. package/scripts/disk-clean.sh +216 -0
  11. package/scripts/rocky-setup.sh +1 -0
  12. package/scripts/ssh-cluster-info.sh +4 -3
  13. package/src/cli/cluster.js +1 -1
  14. package/src/cli/db.js +1143 -201
  15. package/src/cli/deploy.js +93 -24
  16. package/src/cli/env.js +2 -2
  17. package/src/cli/image.js +198 -133
  18. package/src/cli/index.js +111 -44
  19. package/src/cli/lxd.js +73 -74
  20. package/src/cli/monitor.js +20 -9
  21. package/src/cli/repository.js +212 -5
  22. package/src/cli/run.js +207 -74
  23. package/src/cli/ssh.js +642 -14
  24. package/src/client/components/core/CommonJs.js +0 -1
  25. package/src/db/mongo/MongooseDB.js +5 -1
  26. package/src/index.js +1 -1
  27. package/src/monitor.js +11 -1
  28. package/src/server/backup.js +1 -1
  29. package/src/server/conf.js +1 -1
  30. package/src/server/dns.js +242 -1
  31. package/src/server/process.js +6 -1
  32. package/src/server/start.js +2 -0
  33. package/scripts/snap-clean.sh +0 -26
  34. package/src/client/public/default/plantuml/client-conf.svg +0 -1
  35. package/src/client/public/default/plantuml/client-schema.svg +0 -1
  36. package/src/client/public/default/plantuml/cron-conf.svg +0 -1
  37. package/src/client/public/default/plantuml/cron-schema.svg +0 -1
  38. package/src/client/public/default/plantuml/server-conf.svg +0 -1
  39. package/src/client/public/default/plantuml/server-schema.svg +0 -1
  40. package/src/client/public/default/plantuml/ssr-conf.svg +0 -1
  41. package/src/client/public/default/plantuml/ssr-schema.svg +0 -1
package/src/cli/db.js CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
- * UnderpostDB CLI index module
2
+ * UnderpostDB CLI module
3
3
  * @module src/cli/db.js
4
4
  * @namespace UnderpostDB
5
+ * @description Manages database operations, backups, and cluster metadata for Kubernetes deployments.
6
+ * Supports MariaDB and MongoDB with import/export capabilities, Git integration, and multi-pod operations.
5
7
  */
6
8
 
7
9
  import { mergeFile, splitFileFactory } from '../server/conf.js';
8
10
  import { loggerFactory } from '../server/logger.js';
9
11
  import { shellExec } from '../server/process.js';
10
12
  import fs from 'fs-extra';
13
+ import os from 'os';
11
14
  import UnderpostDeploy from './deploy.js';
12
15
  import UnderpostCron from './cron.js';
13
16
  import { DataBaseProvider } from '../db/DataBaseProvider.js';
@@ -16,33 +19,768 @@ import { loadReplicas, pathPortAssignmentFactory } from '../server/conf.js';
16
19
  const logger = loggerFactory(import.meta);
17
20
 
18
21
  /**
19
- * @class UnderpostDB
20
- * @description Manages database operations and backups.
21
- * This class provides a set of static methods to handle database operations,
22
- * including importing and exporting data, managing database backups, and
23
- * handling database connections for different providers (e.g., MariaDB, MongoDB).
22
+ * Constants for database operations
23
+ * @constant {number} MAX_BACKUP_RETENTION - Maximum number of backups to retain
24
+ * @memberof UnderpostDB
25
+ */
26
+ const MAX_BACKUP_RETENTION = 5;
27
+
28
+ /**
29
+ * Timeout for kubectl operations in milliseconds
30
+ * @constant {number} KUBECTL_TIMEOUT
31
+ * @memberof UnderpostDB
32
+ */
33
+ const KUBECTL_TIMEOUT = 300000; // 5 minutes
34
+
35
+ /**
36
+ * @typedef {Object} DatabaseOptions
37
+ * @memberof UnderpostDB
38
+ * @property {boolean} [import=false] - Flag to import data from a backup
39
+ * @property {boolean} [export=false] - Flag to export data to a backup
40
+ * @property {string} [podName=''] - Comma-separated list of pod names or patterns
41
+ * @property {string} [nodeName=''] - Comma-separated list of node names for pod filtering
42
+ * @property {string} [ns='default'] - Kubernetes namespace
43
+ * @property {string} [collections=''] - Comma-separated list of collections to include
44
+ * @property {string} [outPath=''] - Output path for backup files
45
+ * @property {boolean} [drop=false] - Flag to drop the database before importing
46
+ * @property {boolean} [preserveUUID=false] - Flag to preserve UUIDs during import
47
+ * @property {boolean} [git=false] - Flag to enable Git integration
48
+ * @property {string} [hosts=''] - Comma-separated list of hosts to include
49
+ * @property {string} [paths=''] - Comma-separated list of paths to include
50
+ * @property {string} [labelSelector=''] - Kubernetes label selector for pods
51
+ * @property {boolean} [allPods=false] - Flag to target all matching pods
52
+ * @property {boolean} [primaryPod=false] - Flag to automatically detect and use MongoDB primary pod
53
+ * @property {boolean} [stats=false] - Flag to display collection/table statistics
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} PodInfo
58
+ * @memberof UnderpostDB
59
+ * @property {string} NAME - Pod name
60
+ * @property {string} NAMESPACE - Pod namespace
61
+ * @property {string} NODE - Node where pod is running
62
+ * @property {string} STATUS - Pod status
63
+ * @property {string} [IP] - Pod IP address
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} DatabaseConfig
24
68
  * @memberof UnderpostDB
69
+ * @property {string} provider - Database provider (mariadb, mongoose)
70
+ * @property {string} name - Database name
71
+ * @property {string} user - Database user
72
+ * @property {string} password - Database password
73
+ * @property {string} hostFolder - Host folder path
74
+ * @property {string} host - Host identifier
75
+ * @property {string} path - Path identifier
76
+ * @property {number} [currentBackupTimestamp] - Timestamp of current backup
77
+ */
78
+
79
+ /**
80
+ * @class UnderpostDB
81
+ * @description Manages database operations and backups for Kubernetes-based deployments.
82
+ * Provides comprehensive database management including import/export, multi-pod targeting,
83
+ * Git integration, and cluster metadata management.
25
84
  */
26
85
  class UnderpostDB {
27
86
  static API = {
28
87
  /**
88
+ * Helper: Validates namespace name
89
+ * @private
90
+ * @param {string} namespace - Namespace to validate
91
+ * @returns {boolean} True if valid
92
+ */
93
+ _validateNamespace(namespace) {
94
+ if (!namespace || typeof namespace !== 'string') return false;
95
+ // Kubernetes namespace naming rules: lowercase alphanumeric, -, max 63 chars
96
+ return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(namespace) && namespace.length <= 63;
97
+ },
98
+
99
+ /**
100
+ * Helper: Validates pod name
101
+ * @private
102
+ * @param {string} podName - Pod name to validate
103
+ * @returns {boolean} True if valid
104
+ */
105
+ _validatePodName(podName) {
106
+ if (!podName || typeof podName !== 'string') return false;
107
+ // Kubernetes pod naming rules: lowercase alphanumeric, -, max 253 chars
108
+ return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(podName) && podName.length <= 253;
109
+ },
110
+
111
+ /**
112
+ * Helper: Gets filtered pods based on criteria
113
+ * @private
114
+ * @param {Object} criteria - Filter criteria
115
+ * @param {string} [criteria.podNames] - Comma-separated pod name patterns
116
+ * @param {string} [criteria.nodeNames] - Comma-separated node names
117
+ * @param {string} [criteria.namespace='default'] - Kubernetes namespace
118
+ * @param {string} [criteria.labelSelector] - Label selector
119
+ * @param {string} [criteria.deployId] - Deployment ID pattern
120
+ * @returns {Array<PodInfo>} Filtered pod list
121
+ */
122
+ _getFilteredPods(criteria = {}) {
123
+ const { podNames, nodeNames, namespace = 'default', labelSelector, deployId } = criteria;
124
+
125
+ try {
126
+ // Get all pods using UnderpostDeploy.API.get
127
+ let pods = UnderpostDeploy.API.get(deployId || '', 'pods', namespace);
128
+
129
+ // Filter by pod names if specified
130
+ if (podNames) {
131
+ const patterns = podNames.split(',').map((p) => p.trim());
132
+ pods = pods.filter((pod) => {
133
+ return patterns.some((pattern) => {
134
+ // Support wildcards
135
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
136
+ return regex.test(pod.NAME);
137
+ });
138
+ });
139
+ }
140
+
141
+ // Filter by node names if specified (only if NODE is not '<none>')
142
+ if (nodeNames) {
143
+ const nodes = nodeNames.split(',').map((n) => n.trim());
144
+ pods = pods.filter((pod) => {
145
+ // Skip filtering if NODE is '<none>' or undefined
146
+ if (!pod.NODE || pod.NODE === '<none>') {
147
+ return true;
148
+ }
149
+ return nodes.includes(pod.NODE);
150
+ });
151
+ }
152
+
153
+ // Filter by label selector if specified
154
+ if (labelSelector) {
155
+ // Note: UnderpostDeploy.API.get doesn't support label selectors directly
156
+ // This would require a separate kubectl command
157
+ logger.warn('Label selector filtering requires additional implementation');
158
+ }
159
+
160
+ logger.info(`Found ${pods.length} pod(s) matching criteria`, { criteria, podNames: pods.map((p) => p.NAME) });
161
+ return pods;
162
+ } catch (error) {
163
+ logger.error('Error filtering pods', { error: error.message, criteria });
164
+ return [];
165
+ }
166
+ },
167
+
168
+ /**
169
+ * Helper: Executes kubectl command with error handling
170
+ * @private
171
+ * @param {string} command - kubectl command to execute
172
+ * @param {Object} options - Execution options
173
+ * @param {string} [options.context=''] - Command context for logging
174
+ * @returns {string|null} Command output or null on error
175
+ */
176
+ _executeKubectl(command, options = {}) {
177
+ const { context = '' } = options;
178
+
179
+ try {
180
+ logger.info(`Executing kubectl command`, { command, context });
181
+ return shellExec(command, { stdout: true });
182
+ } catch (error) {
183
+ logger.error(`kubectl command failed`, { command, error: error.message, context });
184
+ throw error;
185
+ }
186
+ },
187
+
188
+ /**
189
+ * Helper: Copies file to pod
190
+ * @private
191
+ * @param {Object} params - Copy parameters
192
+ * @param {string} params.sourcePath - Source file path
193
+ * @param {string} params.podName - Target pod name
194
+ * @param {string} params.namespace - Pod namespace
195
+ * @param {string} params.destPath - Destination path in pod
196
+ * @returns {boolean} Success status
197
+ */
198
+ _copyToPod({ sourcePath, podName, namespace, destPath }) {
199
+ try {
200
+ const command = `sudo kubectl cp ${sourcePath} ${namespace}/${podName}:${destPath}`;
201
+ UnderpostDB.API._executeKubectl(command, { context: `copy to pod ${podName}` });
202
+ return true;
203
+ } catch (error) {
204
+ logger.error('Failed to copy file to pod', { sourcePath, podName, destPath, error: error.message });
205
+ return false;
206
+ }
207
+ },
208
+
209
+ /**
210
+ * Helper: Copies file from pod
211
+ * @private
212
+ * @param {Object} params - Copy parameters
213
+ * @param {string} params.podName - Source pod name
214
+ * @param {string} params.namespace - Pod namespace
215
+ * @param {string} params.sourcePath - Source path in pod
216
+ * @param {string} params.destPath - Destination file path
217
+ * @returns {boolean} Success status
218
+ */
219
+ _copyFromPod({ podName, namespace, sourcePath, destPath }) {
220
+ try {
221
+ const command = `sudo kubectl cp ${namespace}/${podName}:${sourcePath} ${destPath}`;
222
+ UnderpostDB.API._executeKubectl(command, { context: `copy from pod ${podName}` });
223
+ return true;
224
+ } catch (error) {
225
+ logger.error('Failed to copy file from pod', { podName, sourcePath, destPath, error: error.message });
226
+ return false;
227
+ }
228
+ },
229
+
230
+ /**
231
+ * Helper: Executes command in pod
232
+ * @private
233
+ * @param {Object} params - Execution parameters
234
+ * @param {string} params.podName - Pod name
235
+ * @param {string} params.namespace - Pod namespace
236
+ * @param {string} params.command - Command to execute
237
+ * @returns {string|null} Command output or null
238
+ */
239
+ _execInPod({ podName, namespace, command }) {
240
+ try {
241
+ const kubectlCmd = `sudo kubectl exec -n ${namespace} -i ${podName} -- sh -c "${command}"`;
242
+ return UnderpostDB.API._executeKubectl(kubectlCmd, { context: `exec in pod ${podName}` });
243
+ } catch (error) {
244
+ logger.error('Failed to execute command in pod', { podName, command, error: error.message });
245
+ throw error;
246
+ }
247
+ },
248
+
249
+ /**
250
+ * Helper: Manages Git repository for backups
251
+ * @private
252
+ * @param {Object} params - Git parameters
253
+ * @param {string} params.repoName - Repository name
254
+ * @param {string} params.operation - Operation (clone, pull, commit, push)
255
+ * @param {string} [params.message=''] - Commit message
256
+ * @returns {boolean} Success status
257
+ */
258
+ _manageGitRepo({ repoName, operation, message = '' }) {
259
+ try {
260
+ const username = process.env.GITHUB_USERNAME;
261
+ if (!username) {
262
+ logger.error('GITHUB_USERNAME environment variable not set');
263
+ return false;
264
+ }
265
+
266
+ const repoPath = `../${repoName}`;
267
+
268
+ switch (operation) {
269
+ case 'clone':
270
+ if (!fs.existsSync(repoPath)) {
271
+ shellExec(`cd .. && underpost clone ${username}/${repoName}`);
272
+ logger.info(`Cloned repository: ${repoName}`);
273
+ }
274
+ break;
275
+
276
+ case 'pull':
277
+ if (fs.existsSync(repoPath)) {
278
+ shellExec(`cd ${repoPath} && git checkout . && git clean -f -d`);
279
+ shellExec(`cd ${repoPath} && underpost pull . ${username}/${repoName}`, {
280
+ silent: true,
281
+ });
282
+ logger.info(`Pulled repository: ${repoName}`);
283
+ }
284
+ break;
285
+
286
+ case 'commit':
287
+ if (fs.existsSync(repoPath)) {
288
+ shellExec(`cd ${repoPath} && git add .`);
289
+ shellExec(`underpost cmt ${repoPath} backup '' '${message}'`);
290
+ logger.info(`Committed to repository: ${repoName}`, { message });
291
+ }
292
+ break;
293
+
294
+ case 'push':
295
+ if (fs.existsSync(repoPath)) {
296
+ shellExec(`cd ${repoPath} && underpost push . ${username}/${repoName}`, { silent: true });
297
+ logger.info(`Pushed repository: ${repoName}`);
298
+ }
299
+ break;
300
+
301
+ default:
302
+ logger.warn(`Unknown git operation: ${operation}`);
303
+ return false;
304
+ }
305
+
306
+ return true;
307
+ } catch (error) {
308
+ logger.error(`Git operation failed`, { repoName, operation, error: error.message });
309
+ return false;
310
+ }
311
+ },
312
+
313
+ /**
314
+ * Helper: Manages backup timestamps and cleanup
315
+ * @private
316
+ * @param {string} backupPath - Backup directory path
317
+ * @param {number} newTimestamp - New backup timestamp
318
+ * @param {boolean} shouldCleanup - Whether to cleanup old backups
319
+ * @returns {Object} Backup info with current and removed timestamps
320
+ */
321
+ _manageBackupTimestamps(backupPath, newTimestamp, shouldCleanup) {
322
+ try {
323
+ if (!fs.existsSync(backupPath)) {
324
+ fs.mkdirSync(backupPath, { recursive: true });
325
+ }
326
+
327
+ // Delete empty folders
328
+ shellExec(`cd ${backupPath} && find . -type d -empty -delete`);
329
+
330
+ const times = fs.readdirSync(backupPath);
331
+ const validTimes = times.map((t) => parseInt(t)).filter((t) => !isNaN(t));
332
+
333
+ const currentBackupTimestamp = validTimes.length > 0 ? Math.max(...validTimes) : null;
334
+ const removeBackupTimestamp = validTimes.length > 0 ? Math.min(...validTimes) : null;
335
+
336
+ // Cleanup old backups if we have too many
337
+ if (shouldCleanup && validTimes.length >= MAX_BACKUP_RETENTION && removeBackupTimestamp) {
338
+ const removeDir = `${backupPath}/${removeBackupTimestamp}`;
339
+ logger.info('Removing old backup', { path: removeDir });
340
+ fs.removeSync(removeDir);
341
+ }
342
+
343
+ // Create new backup directory
344
+ if (shouldCleanup) {
345
+ const newBackupDir = `${backupPath}/${newTimestamp}`;
346
+ logger.info('Creating new backup directory', { path: newBackupDir });
347
+ fs.mkdirSync(newBackupDir, { recursive: true });
348
+ }
349
+
350
+ return {
351
+ current: currentBackupTimestamp,
352
+ removed: removeBackupTimestamp,
353
+ count: validTimes.length,
354
+ };
355
+ } catch (error) {
356
+ logger.error('Error managing backup timestamps', { backupPath, error: error.message });
357
+ return { current: null, removed: null, count: 0 };
358
+ }
359
+ },
360
+
361
+ /**
362
+ * Helper: Performs MariaDB import operation
363
+ * @private
364
+ * @param {Object} params - Import parameters
365
+ * @param {PodInfo} params.pod - Target pod
366
+ * @param {string} params.namespace - Namespace
367
+ * @param {string} params.dbName - Database name
368
+ * @param {string} params.user - Database user
369
+ * @param {string} params.password - Database password
370
+ * @param {string} params.sqlPath - SQL file path
371
+ * @returns {boolean} Success status
372
+ */
373
+ _importMariaDB({ pod, namespace, dbName, user, password, sqlPath }) {
374
+ try {
375
+ const podName = pod.NAME;
376
+ const containerSqlPath = `/${dbName}.sql`;
377
+
378
+ logger.info('Importing MariaDB database', { podName, dbName });
379
+
380
+ // Remove existing SQL file in container
381
+ UnderpostDB.API._execInPod({
382
+ podName,
383
+ namespace,
384
+ command: `rm -rf ${containerSqlPath}`,
385
+ });
386
+
387
+ // Copy SQL file to pod
388
+ if (
389
+ !UnderpostDB.API._copyToPod({
390
+ sourcePath: sqlPath,
391
+ podName,
392
+ namespace,
393
+ destPath: containerSqlPath,
394
+ })
395
+ ) {
396
+ return false;
397
+ }
398
+
399
+ // Create database if it doesn't exist
400
+ UnderpostDB.API._executeKubectl(
401
+ `kubectl exec -n ${namespace} -i ${podName} -- mariadb -p${password} -e 'CREATE DATABASE IF NOT EXISTS ${dbName};'`,
402
+ { context: `create database ${dbName}` },
403
+ );
404
+
405
+ // Import SQL file
406
+ const importCmd = `mariadb -u ${user} -p${password} ${dbName} < ${containerSqlPath}`;
407
+ UnderpostDB.API._execInPod({ podName, namespace, command: importCmd });
408
+
409
+ logger.info('Successfully imported MariaDB database', { podName, dbName });
410
+ return true;
411
+ } catch (error) {
412
+ logger.error('MariaDB import failed', { podName: pod.NAME, dbName, error: error.message });
413
+ return false;
414
+ }
415
+ },
416
+
417
+ /**
418
+ * Helper: Performs MariaDB export operation
419
+ * @private
420
+ * @param {Object} params - Export parameters
421
+ * @param {PodInfo} params.pod - Source pod
422
+ * @param {string} params.namespace - Namespace
423
+ * @param {string} params.dbName - Database name
424
+ * @param {string} params.user - Database user
425
+ * @param {string} params.password - Database password
426
+ * @param {string} params.outputPath - Output file path
427
+ * @returns {boolean} Success status
428
+ */
429
+ async _exportMariaDB({ pod, namespace, dbName, user, password, outputPath }) {
430
+ try {
431
+ const podName = pod.NAME;
432
+ const containerSqlPath = `/home/${dbName}.sql`;
433
+
434
+ logger.info('Exporting MariaDB database', { podName, dbName });
435
+
436
+ // Remove existing SQL file in container
437
+ UnderpostDB.API._execInPod({
438
+ podName,
439
+ namespace,
440
+ command: `rm -rf ${containerSqlPath}`,
441
+ });
442
+
443
+ // Dump database
444
+ const dumpCmd = `mariadb-dump --user=${user} --password=${password} --lock-tables ${dbName} > ${containerSqlPath}`;
445
+ UnderpostDB.API._execInPod({ podName, namespace, command: dumpCmd });
446
+
447
+ // Copy SQL file from pod
448
+ if (
449
+ !UnderpostDB.API._copyFromPod({
450
+ podName,
451
+ namespace,
452
+ sourcePath: containerSqlPath,
453
+ destPath: outputPath,
454
+ })
455
+ ) {
456
+ return false;
457
+ }
458
+
459
+ // Split file if it exists
460
+ if (fs.existsSync(outputPath)) {
461
+ await splitFileFactory(dbName, outputPath);
462
+ }
463
+
464
+ logger.info('Successfully exported MariaDB database', { podName, dbName, outputPath });
465
+ return true;
466
+ } catch (error) {
467
+ logger.error('MariaDB export failed', { podName: pod.NAME, dbName, error: error.message });
468
+ return false;
469
+ }
470
+ },
471
+
472
+ /**
473
+ * Helper: Performs MongoDB import operation
474
+ * @private
475
+ * @param {Object} params - Import parameters
476
+ * @param {PodInfo} params.pod - Target pod
477
+ * @param {string} params.namespace - Namespace
478
+ * @param {string} params.dbName - Database name
479
+ * @param {string} params.bsonPath - BSON directory path
480
+ * @param {boolean} params.drop - Whether to drop existing database
481
+ * @param {boolean} params.preserveUUID - Whether to preserve UUIDs
482
+ * @returns {boolean} Success status
483
+ */
484
+ _importMongoDB({ pod, namespace, dbName, bsonPath, drop, preserveUUID }) {
485
+ try {
486
+ const podName = pod.NAME;
487
+ const containerBsonPath = `/${dbName}`;
488
+
489
+ logger.info('Importing MongoDB database', { podName, dbName });
490
+
491
+ // Remove existing BSON directory in container
492
+ UnderpostDB.API._execInPod({
493
+ podName,
494
+ namespace,
495
+ command: `rm -rf ${containerBsonPath}`,
496
+ });
497
+
498
+ // Copy BSON directory to pod
499
+ if (
500
+ !UnderpostDB.API._copyToPod({
501
+ sourcePath: bsonPath,
502
+ podName,
503
+ namespace,
504
+ destPath: containerBsonPath,
505
+ })
506
+ ) {
507
+ return false;
508
+ }
509
+
510
+ // Restore database
511
+ const restoreCmd = `mongorestore -d ${dbName} ${containerBsonPath}${drop ? ' --drop' : ''}${
512
+ preserveUUID ? ' --preserveUUID' : ''
513
+ }`;
514
+ UnderpostDB.API._execInPod({ podName, namespace, command: restoreCmd });
515
+
516
+ logger.info('Successfully imported MongoDB database', { podName, dbName });
517
+ return true;
518
+ } catch (error) {
519
+ logger.error('MongoDB import failed', { podName: pod.NAME, dbName, error: error.message });
520
+ return false;
521
+ }
522
+ },
523
+
524
+ /**
525
+ * Helper: Performs MongoDB export operation
526
+ * @private
527
+ * @param {Object} params - Export parameters
528
+ * @param {PodInfo} params.pod - Source pod
529
+ * @param {string} params.namespace - Namespace
530
+ * @param {string} params.dbName - Database name
531
+ * @param {string} params.outputPath - Output directory path
532
+ * @param {string} [params.collections=''] - Comma-separated collection list
533
+ * @returns {boolean} Success status
534
+ */
535
+ _exportMongoDB({ pod, namespace, dbName, outputPath, collections = '' }) {
536
+ try {
537
+ const podName = pod.NAME;
538
+ const containerBsonPath = `/${dbName}`;
539
+
540
+ logger.info('Exporting MongoDB database', { podName, dbName, collections });
541
+
542
+ // Remove existing BSON directory in container
543
+ UnderpostDB.API._execInPod({
544
+ podName,
545
+ namespace,
546
+ command: `rm -rf ${containerBsonPath}`,
547
+ });
548
+
549
+ // Dump database or specific collections
550
+ if (collections) {
551
+ const collectionList = collections.split(',').map((c) => c.trim());
552
+ for (const collection of collectionList) {
553
+ const dumpCmd = `mongodump -d ${dbName} --collection ${collection} -o /`;
554
+ UnderpostDB.API._execInPod({ podName, namespace, command: dumpCmd });
555
+ }
556
+ } else {
557
+ const dumpCmd = `mongodump -d ${dbName} -o /`;
558
+ UnderpostDB.API._execInPod({ podName, namespace, command: dumpCmd });
559
+ }
560
+
561
+ // Copy BSON directory from pod
562
+ if (
563
+ !UnderpostDB.API._copyFromPod({
564
+ podName,
565
+ namespace,
566
+ sourcePath: containerBsonPath,
567
+ destPath: outputPath,
568
+ })
569
+ ) {
570
+ return false;
571
+ }
572
+
573
+ logger.info('Successfully exported MongoDB database', { podName, dbName, outputPath });
574
+ return true;
575
+ } catch (error) {
576
+ logger.error('MongoDB export failed', { podName: pod.NAME, dbName, error: error.message });
577
+ return false;
578
+ }
579
+ },
580
+
581
+ /**
582
+ * Helper: Gets MongoDB collection statistics
583
+ * @private
584
+ * @param {Object} params - Parameters
585
+ * @param {string} params.podName - Pod name
586
+ * @param {string} params.namespace - Namespace
587
+ * @param {string} params.dbName - Database name
588
+ * @returns {Object|null} Collection statistics or null on error
589
+ */
590
+ _getMongoStats({ podName, namespace, dbName }) {
591
+ try {
592
+ logger.info('Getting MongoDB collection statistics', { podName, dbName });
593
+
594
+ // Use db.getSiblingDB() instead of 'use' command
595
+ const script = `db.getSiblingDB('${dbName}').getCollectionNames().map(function(c) { return { collection: c, count: db.getSiblingDB('${dbName}')[c].countDocuments() }; })`;
596
+
597
+ // Execute the script
598
+ const command = `sudo kubectl exec -n ${namespace} -i ${podName} -- mongosh --quiet --eval "${script}"`;
599
+ const output = shellExec(command, { stdout: true, silent: true });
600
+
601
+ if (!output || output.trim() === '') {
602
+ logger.warn('No collections found or empty output');
603
+ return null;
604
+ }
605
+
606
+ // Clean the output: remove newlines, handle EJSON format, replace single quotes with double quotes
607
+ let cleanedOutput = output
608
+ .trim()
609
+ .replace(/\n/g, '')
610
+ .replace(/\s+/g, ' ')
611
+ .replace(/NumberLong\("(\d+)"\)/g, '$1')
612
+ .replace(/NumberLong\((\d+)\)/g, '$1')
613
+ .replace(/NumberInt\("(\d+)"\)/g, '$1')
614
+ .replace(/NumberInt\((\d+)\)/g, '$1')
615
+ .replace(/ISODate\("([^"]+)"\)/g, '"$1"')
616
+ .replace(/'/g, '"')
617
+ .replace(/(\w+):/g, '"$1":');
618
+
619
+ try {
620
+ const stats = JSON.parse(cleanedOutput);
621
+ logger.info('MongoDB statistics retrieved', { dbName, collections: stats.length });
622
+ return stats;
623
+ } catch (parseError) {
624
+ logger.error('Failed to parse MongoDB output', {
625
+ podName,
626
+ dbName,
627
+ error: parseError.message,
628
+ rawOutput: output.substring(0, 200),
629
+ cleanedOutput: cleanedOutput.substring(0, 200),
630
+ });
631
+ return null;
632
+ }
633
+ } catch (error) {
634
+ logger.error('Failed to get MongoDB statistics', { podName, dbName, error: error.message });
635
+ return null;
636
+ }
637
+ },
638
+
639
+ /**
640
+ * Helper: Gets MariaDB table statistics
641
+ * @private
642
+ * @param {Object} params - Parameters
643
+ * @param {string} params.podName - Pod name
644
+ * @param {string} params.namespace - Namespace
645
+ * @param {string} params.dbName - Database name
646
+ * @param {string} params.user - Database user
647
+ * @param {string} params.password - Database password
648
+ * @returns {Object|null} Table statistics or null on error
649
+ */
650
+ _getMariaDBStats({ podName, namespace, dbName, user, password }) {
651
+ try {
652
+ logger.info('Getting MariaDB table statistics', { podName, dbName });
653
+
654
+ const command = `sudo kubectl exec -n ${namespace} -i ${podName} -- mariadb -u ${user} -p${password} ${dbName} -e "SELECT TABLE_NAME as 'table', TABLE_ROWS as 'count' FROM information_schema.TABLES WHERE TABLE_SCHEMA = '${dbName}' ORDER BY TABLE_NAME;" --skip-column-names --batch`;
655
+ const output = shellExec(command, { stdout: true, silent: true });
656
+
657
+ if (!output || output.trim() === '') {
658
+ logger.warn('No tables found or empty output');
659
+ return null;
660
+ }
661
+
662
+ // Parse the output (tab-separated values)
663
+ const lines = output.trim().split('\n');
664
+ const stats = lines.map((line) => {
665
+ const [table, count] = line.split('\t');
666
+ return { table, count: parseInt(count) || 0 };
667
+ });
668
+
669
+ logger.info('MariaDB statistics retrieved', { dbName, tables: stats.length });
670
+ return stats;
671
+ } catch (error) {
672
+ logger.error('Failed to get MariaDB statistics', { podName, dbName, error: error.message });
673
+ return null;
674
+ }
675
+ },
676
+
677
+ /**
678
+ * Helper: Displays database statistics in table format
679
+ * @private
680
+ * @param {Object} params - Parameters
681
+ * @param {string} params.provider - Database provider
682
+ * @param {string} params.dbName - Database name
683
+ * @param {Array<Object>} params.stats - Statistics array
684
+ */
685
+ _displayStats({ provider, dbName, stats }) {
686
+ if (!stats || stats.length === 0) {
687
+ logger.warn('No statistics to display', { provider, dbName });
688
+ return;
689
+ }
690
+
691
+ const title = provider === 'mongoose' ? 'Collections' : 'Tables';
692
+ const itemKey = provider === 'mongoose' ? 'collection' : 'table';
693
+
694
+ console.log('\n' + '='.repeat(70));
695
+ console.log(`DATABASE: ${dbName} (${provider.toUpperCase()})`);
696
+ console.log('='.repeat(70));
697
+ console.log(`${title.padEnd(50)} ${'Documents/Rows'.padStart(18)}`);
698
+ console.log('-'.repeat(70));
699
+
700
+ let totalCount = 0;
701
+ stats.forEach((item) => {
702
+ const name = item[itemKey] || 'Unknown';
703
+ const count = item.count || 0;
704
+ totalCount += count;
705
+ console.log(`${name.padEnd(50)} ${count.toString().padStart(18)}`);
706
+ });
707
+
708
+ console.log('-'.repeat(70));
709
+ console.log(`${'TOTAL'.padEnd(50)} ${totalCount.toString().padStart(18)}`);
710
+ console.log('='.repeat(70) + '\n');
711
+ },
712
+
713
+ /**
714
+ * Public API: Gets MongoDB primary pod name
715
+ * @public
716
+ * @param {Object} options - Options for getting primary pod
717
+ * @param {string} [options.namespace='default'] - Kubernetes namespace
718
+ * @param {string} [options.podName='mongodb-0'] - Initial pod name to query replica set status
719
+ * @returns {string|null} Primary pod name or null if not found
720
+ * @memberof UnderpostDB
721
+ * @example
722
+ * const primaryPod = UnderpostDB.API.getMongoPrimaryPodName({ namespace: 'production' });
723
+ * console.log(primaryPod); // 'mongodb-1'
724
+ */
725
+ getMongoPrimaryPodName(options = { namespace: 'default', podName: 'mongodb-0' }) {
726
+ const { namespace = 'default', podName = 'mongodb-0' } = options;
727
+
728
+ try {
729
+ logger.info('Checking for MongoDB primary pod', { namespace, checkingPod: podName });
730
+
731
+ const command = `sudo kubectl exec -n ${namespace} -i ${podName} -- mongosh --quiet --eval 'rs.status().members.filter(m => m.stateStr=="PRIMARY").map(m=>m.name)'`;
732
+ const output = shellExec(command, { stdout: true, silent: true });
733
+
734
+ if (!output || output.trim() === '') {
735
+ logger.warn('No primary pod found in replica set');
736
+ return null;
737
+ }
738
+
739
+ // Parse the output to get the primary pod name
740
+ // Output format: [ 'mongodb-0:27017' ] or [ 'mongodb-1.mongodb-service:27017' ] or similar
741
+ const match = output.match(/['"]([^'"]+)['"]/);
742
+ if (match && match[1]) {
743
+ let primaryName = match[1].split(':')[0]; // Extract pod name without port
744
+ // Remove service suffix if present (e.g., "mongodb-1.mongodb-service" -> "mongodb-1")
745
+ primaryName = primaryName.split('.')[0];
746
+ logger.info('Found MongoDB primary pod', { primaryPod: primaryName });
747
+ return primaryName;
748
+ }
749
+
750
+ logger.warn('Could not parse primary pod from replica set status', { output });
751
+ return null;
752
+ } catch (error) {
753
+ logger.error('Failed to get MongoDB primary pod', { error: error.message });
754
+ return null;
755
+ }
756
+ },
757
+
758
+ /**
759
+ * Main callback: Initiates database backup workflow
29
760
  * @method callback
30
- * @description Initiates a database backup workflow based on the provided options.
31
- * This method orchestrates the backup process for multiple deployments, handling
761
+ * @description Orchestrates the backup process for multiple deployments, handling
32
762
  * database connections, backup storage, and optional Git integration for version control.
33
- * @param {string} [deployList='default'] - List of deployment IDs to include in the backup.
34
- * @param {object} [options] - An object containing boolean flags for various operations.
35
- * @param {boolean} [options.import=false] - Flag to import data from a backup.
36
- * @param {boolean} [options.export=false] - Flag to export data to a backup.
37
- * @param {string} [options.podName=false] - The name of the Kubernetes pod to use for database operations.
38
- * @param {string} [options.ns=false] - The namespace to use for database operations.
39
- * @param {string} [options.collections=''] - Comma-separated list of collections to include in the backup.
40
- * @param {string} [options.outPath=''] - Output path for the backup file.
41
- * @param {boolean} [options.drop=false] - Flag to drop the database before importing.
42
- * @param {boolean} [options.preserveUUID=false] - Flag to preserve UUIDs during import.
43
- * @param {boolean} [options.git=false] - Flag to enable Git integration for version control.
44
- * @param {string} [options.hosts=''] - Comma-separated list of hosts to include in the backup.
45
- * @param {string} [options.paths=''] - Comma-separated list of paths to include in the backup.
763
+ * Supports targeting multiple specific pods, nodes, and namespaces with advanced filtering.
764
+ * @param {string} [deployList='default'] - Comma-separated list of deployment IDs
765
+ * @param {Object} options - Backup options
766
+ * @param {boolean} [options.import=false] - Whether to perform import operation
767
+ * @param {boolean} [options.export=false] - Whether to perform export operation
768
+ * @param {string} [options.podName=''] - Comma-separated pod name patterns to target
769
+ * @param {string} [options.nodeName=''] - Comma-separated node names to target
770
+ * @param {string} [options.ns='default'] - Kubernetes namespace
771
+ * @param {string} [options.collections=''] - Comma-separated MongoDB collections for export
772
+ * @param {string} [options.outPath=''] - Output path for backups
773
+ * @param {boolean} [options.drop=false] - Whether to drop existing database on import
774
+ * @param {boolean} [options.preserveUUID=false] - Whether to preserve UUIDs on MongoDB import
775
+ * @param {boolean} [options.git=false] - Whether to use Git for backup versioning
776
+ * @param {string} [options.hosts=''] - Comma-separated list of hosts to filter databases
777
+ * @param {string} [options.paths=''] - Comma-separated list of paths to filter databases
778
+ * @param {string} [options.labelSelector=''] - Label selector for pod filtering
779
+ * @param {boolean} [options.allPods=false] - Whether to target all pods in deployment
780
+ * @param {boolean} [options.primaryPod=false] - Whether to target MongoDB primary pod only
781
+ * @param {boolean} [options.stats=false] - Whether to display database statistics
782
+ * @param {number} [options.macroRollbackExport=1] - Number of commits to rollback in macro export
783
+ * @returns {Promise<void>} Resolves when operation is complete
46
784
  * @memberof UnderpostDB
47
785
  */
48
786
  async callback(
@@ -50,8 +788,9 @@ class UnderpostDB {
50
788
  options = {
51
789
  import: false,
52
790
  export: false,
53
- podName: false,
54
- ns: false,
791
+ podName: '',
792
+ nodeName: '',
793
+ ns: 'default',
55
794
  collections: '',
56
795
  outPath: '',
57
796
  drop: false,
@@ -59,17 +798,51 @@ class UnderpostDB {
59
798
  git: false,
60
799
  hosts: '',
61
800
  paths: '',
801
+ labelSelector: '',
802
+ allPods: false,
803
+ primaryPod: false,
804
+ stats: false,
805
+ macroRollbackExport: 1,
62
806
  },
63
807
  ) {
64
808
  const newBackupTimestamp = new Date().getTime();
65
- const nameSpace = options.ns && typeof options.ns === 'string' ? options.ns : 'default';
809
+ const namespace = options.ns && typeof options.ns === 'string' ? options.ns : 'default';
810
+
811
+ // Validate namespace
812
+ if (!UnderpostDB.API._validateNamespace(namespace)) {
813
+ logger.error('Invalid namespace format', { namespace });
814
+ throw new Error(`Invalid namespace: ${namespace}`);
815
+ }
816
+
817
+ if (deployList === 'dd') deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8');
818
+
819
+ logger.info('Starting database operation', {
820
+ deployList,
821
+ namespace,
822
+ import: options.import,
823
+ export: options.export,
824
+ });
825
+
66
826
  for (const _deployId of deployList.split(',')) {
67
827
  const deployId = _deployId.trim();
68
828
  if (!deployId) continue;
829
+
830
+ logger.info('Processing deployment', { deployId });
831
+
832
+ /** @type {Object.<string, Object.<string, DatabaseConfig>>} */
69
833
  const dbs = {};
70
834
  const repoName = `engine-${deployId.split('dd-')[1]}-cron-backups`;
71
835
 
72
- const confServer = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.json`, 'utf8'));
836
+ // Load server configuration
837
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
838
+ if (!fs.existsSync(confServerPath)) {
839
+ logger.error('Configuration file not found', { path: confServerPath });
840
+ continue;
841
+ }
842
+
843
+ const confServer = JSON.parse(fs.readFileSync(confServerPath, 'utf8'));
844
+
845
+ // Build database configuration map
73
846
  for (const host of Object.keys(confServer)) {
74
847
  for (const path of Object.keys(confServer[host])) {
75
848
  const { db } = confServer[host][path];
@@ -77,193 +850,307 @@ class UnderpostDB {
77
850
  const { provider, name, user, password } = db;
78
851
  if (!dbs[provider]) dbs[provider] = {};
79
852
 
80
- if (!(name in dbs[provider]))
81
- dbs[provider][name] = { user, password, hostFolder: host + path.replaceAll('/', '-'), host, path };
853
+ if (!(name in dbs[provider])) {
854
+ dbs[provider][name] = {
855
+ user,
856
+ password,
857
+ hostFolder: host + path.replaceAll('/', '-'),
858
+ host,
859
+ path,
860
+ };
861
+ }
82
862
  }
83
863
  }
84
864
  }
85
865
 
866
+ // Handle Git operations
86
867
  if (options.git === true) {
87
- if (!fs.existsSync(`../${repoName}`)) {
88
- shellExec(`cd .. && underpost clone ${process.env.GITHUB_USERNAME}/${repoName}`);
868
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'clone' });
869
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'pull' });
870
+ }
871
+
872
+ if (options.macroRollbackExport) {
873
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'clone' });
874
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'pull' });
875
+
876
+ const nCommits = parseInt(options.macroRollbackExport);
877
+ const repoPath = `../${repoName}`;
878
+ const username = process.env.GITHUB_USERNAME;
879
+
880
+ if (fs.existsSync(repoPath) && username) {
881
+ logger.info('Executing macro rollback export', { repoName, nCommits });
882
+ shellExec(`cd ${repoPath} && underpost cmt . reset ${nCommits}`);
883
+ shellExec(`cd ${repoPath} && git reset`);
884
+ shellExec(`cd ${repoPath} && git checkout .`);
885
+ shellExec(`cd ${repoPath} && git clean -f -d`);
886
+ shellExec(`cd ${repoPath} && underpost push . ${username}/${repoName} -f`);
89
887
  } else {
90
- shellExec(`cd ../${repoName} && git checkout . && git clean -f -d`);
91
- shellExec(`cd ../${repoName} && underpost pull . ${process.env.GITHUB_USERNAME}/${repoName}`);
888
+ if (!username) logger.error('GITHUB_USERNAME environment variable not set');
889
+ logger.warn('Repository not found for macro rollback', { repoPath });
92
890
  }
93
891
  }
94
892
 
893
+ // Process each database provider
95
894
  for (const provider of Object.keys(dbs)) {
96
895
  for (const dbName of Object.keys(dbs[provider])) {
97
896
  const { hostFolder, user, password, host, path } = dbs[provider][dbName];
897
+
898
+ // Filter by hosts and paths if specified
98
899
  if (
99
- (options.hosts && !options.hosts.split(',').includes(host)) ||
100
- (options.paths && !options.paths.split(',').includes(path))
101
- )
900
+ (options.hosts &&
901
+ !options.hosts
902
+ .split(',')
903
+ .map((h) => h.trim())
904
+ .includes(host)) ||
905
+ (options.paths &&
906
+ !options.paths
907
+ .split(',')
908
+ .map((p) => p.trim())
909
+ .includes(path))
910
+ ) {
911
+ logger.info('Skipping database due to host/path filter', { dbName, host, path });
102
912
  continue;
103
- if (hostFolder) {
104
- logger.info('', { hostFolder, provider, dbName });
105
-
106
- const backUpPath = `../${repoName}/${hostFolder}`;
107
- if (!fs.existsSync(backUpPath)) fs.mkdirSync(backUpPath, { recursive: true });
108
- shellExec(`cd ${backUpPath} && find . -type d -empty -delete`); // delete empty folders
109
- const times = await fs.readdir(backUpPath);
110
- const currentBackupTimestamp = Math.max(...times.map((t) => parseInt(t)).filter((t) => !isNaN(t)));
111
- dbs[provider][dbName].currentBackupTimestamp = currentBackupTimestamp;
112
- const removeBackupTimestamp = Math.min(...times.map((t) => parseInt(t)).filter((t) => !isNaN(t)));
113
-
114
- const sqlContainerPath = `/home/${dbName}.sql`;
115
- const _fromPartsParts = `../${repoName}/${hostFolder}/${currentBackupTimestamp}/${dbName}-parths.json`;
116
- const _toSqlPath = `../${repoName}/${hostFolder}/${currentBackupTimestamp}/${dbName}.sql`;
117
- const _toNewSqlPath = `../${repoName}/${hostFolder}/${newBackupTimestamp}/${dbName}.sql`;
118
- const _toBsonPath = `../${repoName}/${hostFolder}/${currentBackupTimestamp}/${dbName}`;
119
- const _toNewBsonPath = `../${repoName}/${hostFolder}/${newBackupTimestamp}/${dbName}`;
120
-
121
- if (options.import === true && fs.existsSync(_fromPartsParts) && !fs.existsSync(_toSqlPath)) {
122
- const names = JSON.parse(fs.readFileSync(_fromPartsParts, 'utf8')).map((_path) => {
123
- return `../${repoName}/${hostFolder}/${currentBackupTimestamp}/${_path.split('/').pop()}`;
124
- });
125
- logger.info('merge Back Up paths', {
126
- _fromPartsParts,
127
- _toSqlPath,
128
- names,
129
- });
130
- await mergeFile(names, _toSqlPath);
131
- }
913
+ }
914
+
915
+ if (!hostFolder) {
916
+ logger.warn('No hostFolder defined for database', { dbName, provider });
917
+ continue;
918
+ }
919
+
920
+ logger.info('Processing database', { hostFolder, provider, dbName });
921
+
922
+ const backUpPath = `../${repoName}/${hostFolder}`;
923
+ const backupInfo = UnderpostDB.API._manageBackupTimestamps(
924
+ backUpPath,
925
+ newBackupTimestamp,
926
+ options.export === true,
927
+ );
928
+
929
+ dbs[provider][dbName].currentBackupTimestamp = backupInfo.current;
930
+
931
+ const currentTimestamp = backupInfo.current || newBackupTimestamp;
932
+ const sqlContainerPath = `/home/${dbName}.sql`;
933
+ const fromPartsPath = `../${repoName}/${hostFolder}/${currentTimestamp}/${dbName}-parths.json`;
934
+ const toSqlPath = `../${repoName}/${hostFolder}/${currentTimestamp}/${dbName}.sql`;
935
+ const toNewSqlPath = `../${repoName}/${hostFolder}/${newBackupTimestamp}/${dbName}.sql`;
936
+ const toBsonPath = `../${repoName}/${hostFolder}/${currentTimestamp}/${dbName}`;
937
+ const toNewBsonPath = `../${repoName}/${hostFolder}/${newBackupTimestamp}/${dbName}`;
938
+
939
+ // Merge split SQL files if needed for import
940
+ if (options.import === true && fs.existsSync(fromPartsPath) && !fs.existsSync(toSqlPath)) {
941
+ const names = JSON.parse(fs.readFileSync(fromPartsPath, 'utf8')).map((_path) => {
942
+ return `../${repoName}/${hostFolder}/${currentTimestamp}/${_path.split('/').pop()}`;
943
+ });
944
+ logger.info('Merging backup parts', { fromPartsPath, toSqlPath, parts: names.length });
945
+ await mergeFile(names, toSqlPath);
946
+ }
947
+
948
+ // Get target pods based on provider and options
949
+ let targetPods = [];
950
+ const podCriteria = {
951
+ podNames: options.podName,
952
+ nodeNames: options.nodeName,
953
+ namespace,
954
+ labelSelector: options.labelSelector,
955
+ deployId: provider === 'mariadb' ? 'mariadb' : 'mongo',
956
+ };
957
+
958
+ targetPods = UnderpostDB.API._getFilteredPods(podCriteria);
959
+
960
+ // Fallback to default if no custom pods specified
961
+ if (targetPods.length === 0 && !options.podName && !options.nodeName) {
962
+ const defaultPods = UnderpostDeploy.API.get(
963
+ provider === 'mariadb' ? 'mariadb' : 'mongo',
964
+ 'pods',
965
+ namespace,
966
+ );
967
+ console.log('defaultPods', defaultPods);
968
+ targetPods = defaultPods;
969
+ }
970
+
971
+ if (targetPods.length === 0) {
972
+ logger.warn('No pods found matching criteria', { provider, criteria: podCriteria });
973
+ continue;
974
+ }
975
+
976
+ // Handle primary pod detection for MongoDB
977
+ let podsToProcess = [];
978
+ if (provider === 'mongoose' && !options.allPods) {
979
+ // For MongoDB, always use primary pod unless allPods is true
980
+ if (!targetPods || targetPods.length === 0) {
981
+ logger.warn('No MongoDB pods available to check for primary');
982
+ podsToProcess = [];
983
+ } else {
984
+ const firstPod = targetPods[0].NAME;
985
+ const primaryPodName = UnderpostDB.API.getMongoPrimaryPodName({ namespace, podName: firstPod });
132
986
 
133
- if (options.export === true && times.length >= 5) {
134
- logger.info('remove', `../${repoName}/${hostFolder}/${removeBackupTimestamp}`);
135
- fs.removeSync(`../${repoName}/${hostFolder}/${removeBackupTimestamp}`);
136
- logger.info('create', `../${repoName}/${hostFolder}/${newBackupTimestamp}`);
137
- fs.mkdirSync(`../${repoName}/${hostFolder}/${newBackupTimestamp}`, { recursive: true });
987
+ if (primaryPodName) {
988
+ const primaryPod = targetPods.find((p) => p.NAME === primaryPodName);
989
+ if (primaryPod) {
990
+ podsToProcess = [primaryPod];
991
+ logger.info('Using MongoDB primary pod', { primaryPod: primaryPodName });
992
+ } else {
993
+ logger.warn('Primary pod not in filtered list, using first pod', { primaryPodName });
994
+ podsToProcess = [targetPods[0]];
995
+ }
996
+ } else {
997
+ logger.warn('Could not detect primary pod, using first pod');
998
+ podsToProcess = [targetPods[0]];
999
+ }
138
1000
  }
1001
+ } else {
1002
+ // For MariaDB or when allPods is true, limit to first pod unless allPods is true
1003
+ podsToProcess = options.allPods === true ? targetPods : [targetPods[0]];
1004
+ }
1005
+
1006
+ logger.info(`Processing ${podsToProcess.length} pod(s) for ${provider}`, {
1007
+ dbName,
1008
+ pods: podsToProcess.map((p) => p.NAME),
1009
+ });
1010
+
1011
+ // Process each pod
1012
+ for (const pod of podsToProcess) {
1013
+ logger.info('Processing pod', { podName: pod.NAME, node: pod.NODE, status: pod.STATUS });
139
1014
 
140
1015
  switch (provider) {
141
1016
  case 'mariadb': {
142
- const podNames =
143
- options.podName && typeof options.podName === 'string'
144
- ? options.podName.split(',')
145
- : UnderpostDeploy.API.get('mariadb'); // `mariadb-statefulset-0`;
146
- const serviceName = 'mariadb';
147
- for (const podNameData of [podNames[0]]) {
148
- const podName = podNameData.NAME;
149
- if (options.import === true) {
150
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "rm -rf /${dbName}.sql"`);
151
- shellExec(`sudo kubectl cp ${_toSqlPath} ${nameSpace}/${podName}:/${dbName}.sql`);
152
- const cmd = `mariadb -u ${user} -p${password} ${dbName} < /${dbName}.sql`;
153
- shellExec(
154
- `kubectl exec -n ${nameSpace} -i ${podName} -- ${serviceName} -p${password} -e 'CREATE DATABASE ${dbName};'`,
155
- );
156
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "${cmd}"`);
157
- }
158
- if (options.export === true) {
159
- shellExec(
160
- `sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "rm -rf ${sqlContainerPath}"`,
161
- );
162
- const cmd = `mariadb-dump --user=${user} --password=${password} --lock-tables ${dbName} > ${sqlContainerPath}`;
163
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "${cmd}"`);
164
- shellExec(
165
- `sudo kubectl cp ${nameSpace}/${podName}:${sqlContainerPath} ${
166
- options.outPath ? options.outPath : _toNewSqlPath
167
- }`,
168
- );
169
- await splitFileFactory(dbName, options.outPath ? options.outPath : _toNewSqlPath);
1017
+ if (options.stats === true) {
1018
+ const stats = UnderpostDB.API._getMariaDBStats({
1019
+ podName: pod.NAME,
1020
+ namespace,
1021
+ dbName,
1022
+ user,
1023
+ password,
1024
+ });
1025
+ if (stats) {
1026
+ UnderpostDB.API._displayStats({ provider, dbName, stats });
170
1027
  }
171
1028
  }
1029
+
1030
+ if (options.import === true) {
1031
+ UnderpostDB.API._importMariaDB({
1032
+ pod,
1033
+ namespace,
1034
+ dbName,
1035
+ user,
1036
+ password,
1037
+ sqlPath: toSqlPath,
1038
+ });
1039
+ }
1040
+
1041
+ if (options.export === true) {
1042
+ const outputPath = options.outPath || toNewSqlPath;
1043
+ await UnderpostDB.API._exportMariaDB({
1044
+ pod,
1045
+ namespace,
1046
+ dbName,
1047
+ user,
1048
+ password,
1049
+ outputPath,
1050
+ });
1051
+ }
172
1052
  break;
173
1053
  }
174
1054
 
175
1055
  case 'mongoose': {
176
- if (options.import === true) {
177
- const podNames =
178
- options.podName && typeof options.podName === 'string'
179
- ? options.podName.split(',')
180
- : UnderpostDeploy.API.get('mongo');
181
- // `mongodb-0`;
182
- for (const podNameData of [podNames[0]]) {
183
- const podName = podNameData.NAME;
184
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "rm -rf /${dbName}"`);
185
- shellExec(
186
- `sudo kubectl cp ${
187
- options.outPath ? options.outPath : _toBsonPath
188
- } ${nameSpace}/${podName}:/${dbName}`,
189
- );
190
- const cmd = `mongorestore -d ${dbName} /${dbName}${options.drop ? ' --drop' : ''}${
191
- options.preserveUUID ? ' --preserveUUID' : ''
192
- }`;
193
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "${cmd}"`);
1056
+ if (options.stats === true) {
1057
+ const stats = UnderpostDB.API._getMongoStats({
1058
+ podName: pod.NAME,
1059
+ namespace,
1060
+ dbName,
1061
+ });
1062
+ if (stats) {
1063
+ UnderpostDB.API._displayStats({ provider, dbName, stats });
194
1064
  }
195
1065
  }
1066
+
1067
+ if (options.import === true) {
1068
+ const bsonPath = options.outPath || toBsonPath;
1069
+ UnderpostDB.API._importMongoDB({
1070
+ pod,
1071
+ namespace,
1072
+ dbName,
1073
+ bsonPath,
1074
+ drop: options.drop,
1075
+ preserveUUID: options.preserveUUID,
1076
+ });
1077
+ }
1078
+
196
1079
  if (options.export === true) {
197
- const podNames =
198
- options.podName && typeof options.podName === 'string'
199
- ? options.podName.split(',')
200
- : UnderpostDeploy.API.get('mongo'); // `backup-access`;
201
- for (const podNameData of [podNames[0]]) {
202
- const podName = podNameData.NAME;
203
- shellExec(`sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "rm -rf /${dbName}"`);
204
- if (options.collections)
205
- for (const collection of options.collections.split(','))
206
- shellExec(
207
- `sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "mongodump -d ${dbName} --collection ${collection} -o /"`,
208
- );
209
- else
210
- shellExec(
211
- `sudo kubectl exec -n ${nameSpace} -i ${podName} -- sh -c "mongodump -d ${dbName} -o /"`,
212
- );
213
- shellExec(
214
- `sudo kubectl cp ${nameSpace}/${podName}:/${dbName} ${
215
- options.outPath ? options.outPath : _toNewBsonPath
216
- }`,
217
- );
218
- }
1080
+ const outputPath = options.outPath || toNewBsonPath;
1081
+ UnderpostDB.API._exportMongoDB({
1082
+ pod,
1083
+ namespace,
1084
+ dbName,
1085
+ outputPath,
1086
+ collections: options.collections,
1087
+ });
219
1088
  }
220
1089
  break;
221
1090
  }
222
1091
 
223
1092
  default:
1093
+ logger.warn('Unsupported database provider', { provider });
224
1094
  break;
225
1095
  }
226
1096
  }
227
1097
  }
228
1098
  }
1099
+
1100
+ // Commit and push to Git if enabled
229
1101
  if (options.export === true && options.git === true) {
230
- shellExec(`cd ../${repoName} && git add .`);
231
- shellExec(
232
- `underpost cmt ../${repoName} backup '' '${new Date(newBackupTimestamp).toLocaleDateString()} ${new Date(
233
- newBackupTimestamp,
234
- ).toLocaleTimeString()}'`,
235
- );
236
- shellExec(`cd ../${repoName} && underpost push . ${process.env.GITHUB_USERNAME}/${repoName}`, {
237
- disableLog: true,
238
- });
1102
+ const commitMessage = `${new Date(newBackupTimestamp).toLocaleDateString()} ${new Date(
1103
+ newBackupTimestamp,
1104
+ ).toLocaleTimeString()}`;
1105
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'commit', message: commitMessage });
1106
+ UnderpostDB.API._manageGitRepo({ repoName, operation: 'push' });
239
1107
  }
240
1108
  }
1109
+
1110
+ logger.info('Database operation completed successfully');
241
1111
  },
242
1112
 
243
1113
  /**
1114
+ * Creates cluster metadata for the specified deployment
244
1115
  * @method clusterMetadataFactory
245
- * @description Creates a cluster metadata object for the specified deployment.
246
- * This method loads database configuration and initializes a cluster metadata object
247
- * using the provided deployment ID, host, and path.
248
- * @param {string} [deployId=process.env.DEFAULT_DEPLOY_ID] - The deployment ID to use.
249
- * @param {string} [host=process.env.DEFAULT_DEPLOY_HOST] - The host to use.
250
- * @param {string} [path=process.env.DEFAULT_DEPLOY_PATH] - The path to use.
1116
+ * @description Loads database configuration and initializes cluster metadata including
1117
+ * instances and cron jobs. This method populates the database with deployment information.
1118
+ * @param {string} [deployId=process.env.DEFAULT_DEPLOY_ID] - The deployment ID
1119
+ * @param {string} [host=process.env.DEFAULT_DEPLOY_HOST] - The host identifier
1120
+ * @param {string} [path=process.env.DEFAULT_DEPLOY_PATH] - The path identifier
1121
+ * @returns {Promise<void>}
251
1122
  * @memberof UnderpostDB
1123
+ * @throws {Error} If database configuration is invalid or connection fails
252
1124
  */
253
1125
  async clusterMetadataFactory(
254
1126
  deployId = process.env.DEFAULT_DEPLOY_ID,
255
1127
  host = process.env.DEFAULT_DEPLOY_HOST,
256
1128
  path = process.env.DEFAULT_DEPLOY_PATH,
257
1129
  ) {
258
- deployId = deployId ?? process.env.DEFAULT_DEPLOY_ID;
259
- host = host ?? process.env.DEFAULT_DEPLOY_HOST;
260
- path = path ?? process.env.DEFAULT_DEPLOY_PATH;
1130
+ deployId = deployId ? deployId : process.env.DEFAULT_DEPLOY_ID;
1131
+ host = host ? host : process.env.DEFAULT_DEPLOY_HOST;
1132
+ path = path ? path : process.env.DEFAULT_DEPLOY_PATH;
1133
+
1134
+ logger.info('Creating cluster metadata', { deployId, host, path });
1135
+
261
1136
  const env = 'production';
262
- const deployList = fs.readFileSync('./engine-private/deploy/dd.router', 'utf8').split(',');
1137
+ const deployListPath = './engine-private/deploy/dd.router';
1138
+
1139
+ if (!fs.existsSync(deployListPath)) {
1140
+ logger.error('Deploy router file not found', { path: deployListPath });
1141
+ throw new Error(`Deploy router file not found: ${deployListPath}`);
1142
+ }
1143
+
1144
+ const deployList = fs.readFileSync(deployListPath, 'utf8').split(',');
1145
+
1146
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
1147
+ if (!fs.existsSync(confServerPath)) {
1148
+ logger.error('Server configuration not found', { path: confServerPath });
1149
+ throw new Error(`Server configuration not found: ${confServerPath}`);
1150
+ }
1151
+
1152
+ const { db } = JSON.parse(fs.readFileSync(confServerPath, 'utf8'))[host][path];
263
1153
 
264
- const { db } = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.json`, 'utf8'))[host][
265
- path
266
- ];
267
1154
  try {
268
1155
  await DataBaseProvider.load({ apis: ['instance', 'cron'], host, path, db });
269
1156
 
@@ -271,14 +1158,21 @@ class UnderpostDB {
271
1158
  const Instance = DataBaseProvider.instance[`${host}${path}`].mongoose.models.Instance;
272
1159
 
273
1160
  await Instance.deleteMany();
1161
+ logger.info('Cleared existing instance metadata');
274
1162
 
275
1163
  for (const _deployId of deployList) {
276
1164
  const deployId = _deployId.trim();
277
1165
  if (!deployId) continue;
278
- const confServer = loadReplicas(
279
- deployId,
280
- JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.json`, 'utf8')),
281
- );
1166
+
1167
+ logger.info('Processing deployment for metadata', { deployId });
1168
+
1169
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
1170
+ if (!fs.existsSync(confServerPath)) {
1171
+ logger.warn('Configuration not found for deployment', { deployId, path: confServerPath });
1172
+ continue;
1173
+ }
1174
+
1175
+ const confServer = loadReplicas(deployId, JSON.parse(fs.readFileSync(confServerPath, 'utf8')));
282
1176
  const router = await UnderpostDeploy.API.routerFactory(deployId, env);
283
1177
  const pathPortAssignmentData = await pathPortAssignmentFactory(deployId, router, confServer);
284
1178
 
@@ -287,6 +1181,8 @@ class UnderpostDB {
287
1181
  if (!confServer[host][path]) continue;
288
1182
 
289
1183
  const { client, runtime, apis, peer } = confServer[host][path];
1184
+
1185
+ // Save main instance
290
1186
  {
291
1187
  const body = {
292
1188
  deployId,
@@ -298,10 +1194,11 @@ class UnderpostDB {
298
1194
  apis,
299
1195
  };
300
1196
 
301
- logger.info('Instance save', body);
1197
+ logger.info('Saving instance metadata', body);
302
1198
  await new Instance(body).save();
303
1199
  }
304
1200
 
1201
+ // Save peer instance if exists
305
1202
  if (peer) {
306
1203
  const body = {
307
1204
  deployId,
@@ -311,15 +1208,16 @@ class UnderpostDB {
311
1208
  runtime: 'nodejs',
312
1209
  };
313
1210
 
314
- logger.info('Instance save', body);
1211
+ logger.info('Saving peer instance metadata', body);
315
1212
  await new Instance(body).save();
316
1213
  }
317
1214
  }
318
1215
  }
319
- if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`)) {
320
- const confInstances = JSON.parse(
321
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
322
- );
1216
+
1217
+ // Process additional instances
1218
+ const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
1219
+ if (fs.existsSync(confInstancesPath)) {
1220
+ const confInstances = JSON.parse(fs.readFileSync(confInstancesPath, 'utf8'));
323
1221
  for (const instance of confInstances) {
324
1222
  const { id, host, path, fromPort, metadata } = instance;
325
1223
  const { runtime } = metadata;
@@ -331,18 +1229,31 @@ class UnderpostDB {
331
1229
  client: id,
332
1230
  runtime,
333
1231
  };
334
- logger.info('Instance save', body);
1232
+ logger.info('Saving additional instance metadata', body);
335
1233
  await new Instance(body).save();
336
1234
  }
337
1235
  }
338
1236
  }
339
1237
  } catch (error) {
340
- logger.error(error, error.stack);
1238
+ logger.error('Failed to create instance metadata', { error: error.message, stack: error.stack });
1239
+ throw error;
341
1240
  }
342
1241
 
343
1242
  try {
344
- const cronDeployId = fs.readFileSync('./engine-private/deploy/dd.cron', 'utf8').trim();
1243
+ const cronDeployPath = './engine-private/deploy/dd.cron';
1244
+ if (!fs.existsSync(cronDeployPath)) {
1245
+ logger.warn('Cron deploy file not found', { path: cronDeployPath });
1246
+ return;
1247
+ }
1248
+
1249
+ const cronDeployId = fs.readFileSync(cronDeployPath, 'utf8').trim();
345
1250
  const confCronPath = `./engine-private/conf/${cronDeployId}/conf.cron.json`;
1251
+
1252
+ if (!fs.existsSync(confCronPath)) {
1253
+ logger.warn('Cron configuration not found', { path: confCronPath });
1254
+ return;
1255
+ }
1256
+
346
1257
  const confCron = JSON.parse(fs.readFileSync(confCronPath, 'utf8'));
347
1258
 
348
1259
  await DataBaseProvider.load({ apis: ['cron'], host, path, db });
@@ -351,6 +1262,7 @@ class UnderpostDB {
351
1262
  const Cron = DataBaseProvider.instance[`${host}${path}`].mongoose.models.Cron;
352
1263
 
353
1264
  await Cron.deleteMany();
1265
+ logger.info('Cleared existing cron metadata');
354
1266
 
355
1267
  for (const jobId of Object.keys(confCron.jobs)) {
356
1268
  const body = {
@@ -359,33 +1271,36 @@ class UnderpostDB {
359
1271
  expression: confCron.jobs[jobId].expression,
360
1272
  enabled: confCron.jobs[jobId].enabled,
361
1273
  };
362
- logger.info('Cron save', body);
1274
+ logger.info('Saving cron metadata', body);
363
1275
  await new Cron(body).save();
364
1276
  }
365
1277
  } catch (error) {
366
- logger.error(error, error.stack);
1278
+ logger.error('Failed to create cron metadata', { error: error.message, stack: error.stack });
367
1279
  }
1280
+
368
1281
  await DataBaseProvider.instance[`${host}${path}`].mongoose.close();
1282
+ logger.info('Cluster metadata creation completed');
369
1283
  },
370
1284
 
371
1285
  /**
1286
+ * Handles backup of cluster metadata
372
1287
  * @method clusterMetadataBackupCallback
373
- * @description Handles the backup of cluster metadata for the specified deployment.
374
- * This method orchestrates the backup process for cluster metadata, including
375
- * instances and crons, and handles optional Git integration for version control.
376
- * @param {string} [deployId=process.env.DEFAULT_DEPLOY_ID] - The deployment ID to use.
377
- * @param {string} [host=process.env.DEFAULT_DEPLOY_HOST] - The host to use.
378
- * @param {string} [path=process.env.DEFAULT_DEPLOY_PATH] - The path to use.
379
- * @param {object} [options] - An object containing boolean flags for various operations.
380
- * @param {boolean} [options.generate=false] - Flag to generate cluster metadata.
381
- * @param {boolean} [options.itc=false] - Flag to enable Git integration for version control.
382
- * @param {boolean} [options.import=false] - Flag to import data from a backup.
383
- * @param {boolean} [options.export=false] - Flag to export data to a backup.
384
- * @param {boolean} [options.instances=false] - Flag to backup instances.
385
- * @param {boolean} [options.crons=false] - Flag to backup crons.
1288
+ * @description Orchestrates backup and restore operations for cluster metadata including
1289
+ * instances and cron jobs. Supports import/export and metadata generation.
1290
+ * @param {string} [deployId=process.env.DEFAULT_DEPLOY_ID] - The deployment ID
1291
+ * @param {string} [host=process.env.DEFAULT_DEPLOY_HOST] - The host identifier
1292
+ * @param {string} [path=process.env.DEFAULT_DEPLOY_PATH] - The path identifier
1293
+ * @param {Object} [options] - Backup operation options
1294
+ * @param {boolean} [options.generate=false] - Generate cluster metadata
1295
+ * @param {boolean} [options.itc=false] - Execute in container context
1296
+ * @param {boolean} [options.import=false] - Import metadata from backup
1297
+ * @param {boolean} [options.export=false] - Export metadata to backup
1298
+ * @param {boolean} [options.instances=false] - Process instances collection
1299
+ * @param {boolean} [options.crons=false] - Process crons collection
1300
+ * @returns {void}
386
1301
  * @memberof UnderpostDB
387
1302
  */
388
- clusterMetadataBackupCallback(
1303
+ async clusterMetadataBackupCallback(
389
1304
  deployId = process.env.DEFAULT_DEPLOY_ID,
390
1305
  host = process.env.DEFAULT_DEPLOY_HOST,
391
1306
  path = process.env.DEFAULT_DEPLOY_PATH,
@@ -398,40 +1313,67 @@ class UnderpostDB {
398
1313
  crons: false,
399
1314
  },
400
1315
  ) {
401
- deployId = deployId ?? process.env.DEFAULT_DEPLOY_ID;
402
- host = host ?? process.env.DEFAULT_DEPLOY_HOST;
403
- path = path ?? process.env.DEFAULT_DEPLOY_PATH;
1316
+ deployId = deployId ? deployId : process.env.DEFAULT_DEPLOY_ID;
1317
+ host = host ? host : process.env.DEFAULT_DEPLOY_HOST;
1318
+ path = path ? path : process.env.DEFAULT_DEPLOY_PATH;
1319
+
1320
+ logger.info('Starting cluster metadata backup operation', {
1321
+ deployId,
1322
+ host,
1323
+ path,
1324
+ options,
1325
+ });
404
1326
 
405
1327
  if (options.generate === true) {
406
- UnderpostDB.API.clusterMetadataFactory(deployId, host, path);
1328
+ logger.info('Generating cluster metadata');
1329
+ await UnderpostDB.API.clusterMetadataFactory(deployId, host, path);
407
1330
  }
408
1331
 
409
1332
  if (options.instances === true) {
410
1333
  const outputPath = './engine-private/instances';
411
- if (fs.existsSync(outputPath)) fs.mkdirSync(outputPath, { recursive: true });
1334
+ if (!fs.existsSync(outputPath)) {
1335
+ fs.mkdirSync(outputPath, { recursive: true });
1336
+ }
412
1337
  const collection = 'instances';
413
- if (options.export === true)
1338
+
1339
+ if (options.export === true) {
1340
+ logger.info('Exporting instances collection', { outputPath });
414
1341
  shellExec(
415
- `node bin db --export --collections ${collection} --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
1342
+ `node bin db --export --primary-pod --collections ${collection} --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
416
1343
  );
417
- if (options.import === true)
1344
+ }
1345
+
1346
+ if (options.import === true) {
1347
+ logger.info('Importing instances collection', { outputPath });
418
1348
  shellExec(
419
- `node bin db --import --drop --preserveUUID --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
1349
+ `node bin db --import --primary-pod --drop --preserveUUID --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
420
1350
  );
1351
+ }
421
1352
  }
1353
+
422
1354
  if (options.crons === true) {
423
1355
  const outputPath = './engine-private/crons';
424
- if (fs.existsSync(outputPath)) fs.mkdirSync(outputPath, { recursive: true });
1356
+ if (!fs.existsSync(outputPath)) {
1357
+ fs.mkdirSync(outputPath, { recursive: true });
1358
+ }
425
1359
  const collection = 'crons';
426
- if (options.export === true)
1360
+
1361
+ if (options.export === true) {
1362
+ logger.info('Exporting crons collection', { outputPath });
427
1363
  shellExec(
428
- `node bin db --export --collections ${collection} --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
1364
+ `node bin db --export --primary-pod --collections ${collection} --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
429
1365
  );
430
- if (options.import === true)
1366
+ }
1367
+
1368
+ if (options.import === true) {
1369
+ logger.info('Importing crons collection', { outputPath });
431
1370
  shellExec(
432
- `node bin db --import --drop --preserveUUID --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
1371
+ `node bin db --import --primary-pod --drop --preserveUUID --out-path ${outputPath} --hosts ${host} --paths '${path}' ${deployId}`,
433
1372
  );
1373
+ }
434
1374
  }
1375
+
1376
+ logger.info('Cluster metadata backup operation completed');
435
1377
  },
436
1378
  };
437
1379
  }