mongodb-backup-service 1.0.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.
- package/.env.example +25 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/cli.js +127 -0
- package/examples/basic-usage.js +51 -0
- package/package.json +58 -0
- package/src/config/config.js +49 -0
- package/src/config/defaults.js +29 -0
- package/src/core/backupLock.js +50 -0
- package/src/core/backupManager.js +114 -0
- package/src/core/backupStatus.js +62 -0
- package/src/database/collectionReader.js +48 -0
- package/src/database/mongoConnection.js +39 -0
- package/src/database/mongoDump.js +45 -0
- package/src/exporters/excelExporter.js +152 -0
- package/src/index.js +71 -0
- package/src/notifications/emailNotifier.js +86 -0
- package/src/notifications/emailTemplates.js +33 -0
- package/src/retention/retentionManager.js +44 -0
- package/src/scheduler/scheduler.js +162 -0
- package/src/storage/localStorage.js +56 -0
- package/src/storage/storageManager.js +14 -0
- package/src/utils/commandRunner.js +42 -0
- package/src/utils/dateUtils.js +18 -0
- package/src/utils/fileManager.js +24 -0
- package/src/utils/logger.js +24 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const configManager = require('../config/config');
|
|
3
|
+
const BackupLock = require('./backupLock');
|
|
4
|
+
const BackupStatus = require('./backupStatus');
|
|
5
|
+
const StorageManager = require('../storage/storageManager');
|
|
6
|
+
const MongoDump = require('../database/mongoDump');
|
|
7
|
+
const ExcelExporter = require('../exporters/excelExporter');
|
|
8
|
+
const logger = require('../utils/logger');
|
|
9
|
+
const DateUtils = require('../utils/dateUtils');
|
|
10
|
+
const RetentionManager = require('../retention/retentionManager');
|
|
11
|
+
const EmailNotifier = require('../notifications/emailNotifier');
|
|
12
|
+
|
|
13
|
+
class BackupManager {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.config = configManager.get();
|
|
16
|
+
this.storageManager = new StorageManager(this.config);
|
|
17
|
+
this.storage = this.storageManager.getProvider();
|
|
18
|
+
this.lock = new BackupLock(this.config.storage.path);
|
|
19
|
+
this.retentionManager = new RetentionManager(this.config, this.storage);
|
|
20
|
+
this.emailNotifier = new EmailNotifier(this.config);
|
|
21
|
+
// Verify SMTP at startup so failures are visible immediately, not silently at send time.
|
|
22
|
+
// verifyConnection() is non-blocking and never exposes credentials.
|
|
23
|
+
this.emailNotifier.verifyConnection();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async createBackup(targetDateStr = null) {
|
|
27
|
+
if (!this.lock.acquire()) {
|
|
28
|
+
logger.warn("Backup aborted because another backup is already running.");
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dateStr = targetDateStr || DateUtils.getCurrentDateString(this.config.schedule.timezone);
|
|
33
|
+
logger.info(`Starting backup process for date: ${dateStr}`);
|
|
34
|
+
|
|
35
|
+
// Prevent duplicate successful backups
|
|
36
|
+
const existingMetadata = this.storage.readMetadata(dateStr);
|
|
37
|
+
if (existingMetadata && existingMetadata.status === 'success') {
|
|
38
|
+
logger.info(`A successful backup already exists for ${dateStr}. Skipping.`);
|
|
39
|
+
this.lock.release();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const status = new BackupStatus(this.config.projectName, dateStr);
|
|
44
|
+
const backupDir = this.storage.ensureBackupDirectory(dateStr);
|
|
45
|
+
|
|
46
|
+
// Initial pending metadata write
|
|
47
|
+
this.storage.writeMetadata(dateStr, status.get());
|
|
48
|
+
|
|
49
|
+
const mongoDumpFile = 'mongodb.archive.gz';
|
|
50
|
+
const mongoDumpPath = path.join(backupDir, mongoDumpFile);
|
|
51
|
+
|
|
52
|
+
const excelFile = 'database.xlsx';
|
|
53
|
+
const excelPath = path.join(backupDir, excelFile);
|
|
54
|
+
|
|
55
|
+
let mongoSuccess = false;
|
|
56
|
+
let excelSuccess = false;
|
|
57
|
+
|
|
58
|
+
// 1. Run MongoDB Dump
|
|
59
|
+
try {
|
|
60
|
+
await MongoDump.execute(this.config.mongoUri, mongoDumpPath);
|
|
61
|
+
status.markMongoDumpSuccess(mongoDumpFile);
|
|
62
|
+
mongoSuccess = true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
status.markMongoDumpFailed(error.message);
|
|
65
|
+
logger.error("MongoDB dump component failed.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Run Excel Export if enabled
|
|
69
|
+
if (this.config.excel.enabled) {
|
|
70
|
+
try {
|
|
71
|
+
await ExcelExporter.export(this.config.mongoUri, excelPath, this.config.excel.excludeCollections);
|
|
72
|
+
status.markExcelSuccess(excelFile);
|
|
73
|
+
excelSuccess = true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
status.markExcelFailed(error.message);
|
|
76
|
+
logger.error("Excel export component failed.");
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
status.markExcelNotApplicable();
|
|
80
|
+
excelSuccess = true; // Not required
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Evaluate overall success. Mongo dump MUST succeed.
|
|
84
|
+
const isOverallSuccess = mongoSuccess && (!this.config.excel.enabled || excelSuccess);
|
|
85
|
+
|
|
86
|
+
const finalMetadata = status.finish(isOverallSuccess);
|
|
87
|
+
this.storage.writeMetadata(dateStr, finalMetadata);
|
|
88
|
+
|
|
89
|
+
if (isOverallSuccess) {
|
|
90
|
+
logger.info("Backup completed successfully overall.");
|
|
91
|
+
|
|
92
|
+
// Run Retention Cleanup
|
|
93
|
+
if (this.config.retention.enabled) {
|
|
94
|
+
try {
|
|
95
|
+
this.retentionManager.runCleanup();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logger.error("Retention cleanup failed, but backup was successful.", err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
logger.error("Backup process completed with failures.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Send Email Notification.
|
|
105
|
+
// sendNotification() handles the enabled/transporter guards internally.
|
|
106
|
+
// A failed notification must NEVER affect the backup result already written to metadata.
|
|
107
|
+
await this.emailNotifier.sendNotification(finalMetadata);
|
|
108
|
+
|
|
109
|
+
this.lock.release();
|
|
110
|
+
return isOverallSuccess;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = BackupManager;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const DateUtils = require('../utils/dateUtils');
|
|
2
|
+
|
|
3
|
+
class BackupStatus {
|
|
4
|
+
constructor(projectName, backupDate) {
|
|
5
|
+
this.metadata = {
|
|
6
|
+
projectName: projectName,
|
|
7
|
+
backupDate: backupDate,
|
|
8
|
+
startedAt: DateUtils.getIsoString(),
|
|
9
|
+
completedAt: null,
|
|
10
|
+
status: 'in_progress',
|
|
11
|
+
mongoDump: {
|
|
12
|
+
status: 'not_started',
|
|
13
|
+
file: null,
|
|
14
|
+
error: null
|
|
15
|
+
},
|
|
16
|
+
excel: {
|
|
17
|
+
status: 'not_started',
|
|
18
|
+
file: null,
|
|
19
|
+
error: null
|
|
20
|
+
},
|
|
21
|
+
durationMs: 0
|
|
22
|
+
};
|
|
23
|
+
this.startTime = Date.now();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
markMongoDumpSuccess(fileName) {
|
|
27
|
+
this.metadata.mongoDump.status = 'success';
|
|
28
|
+
this.metadata.mongoDump.file = fileName;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
markMongoDumpFailed(errorMsg) {
|
|
32
|
+
this.metadata.mongoDump.status = 'failed';
|
|
33
|
+
this.metadata.mongoDump.error = errorMsg;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
markExcelSuccess(fileName) {
|
|
37
|
+
this.metadata.excel.status = 'success';
|
|
38
|
+
this.metadata.excel.file = fileName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
markExcelFailed(errorMsg) {
|
|
42
|
+
this.metadata.excel.status = 'failed';
|
|
43
|
+
this.metadata.excel.error = errorMsg;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
markExcelNotApplicable() {
|
|
47
|
+
this.metadata.excel.status = 'not_started';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
finish(isOverallSuccess) {
|
|
51
|
+
this.metadata.status = isOverallSuccess ? 'success' : 'failed';
|
|
52
|
+
this.metadata.completedAt = DateUtils.getIsoString();
|
|
53
|
+
this.metadata.durationMs = Date.now() - this.startTime;
|
|
54
|
+
return this.metadata;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get() {
|
|
58
|
+
return this.metadata;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = BackupStatus;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const logger = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
class CollectionReader {
|
|
4
|
+
/**
|
|
5
|
+
* Generator function to safely read collection documents in batches.
|
|
6
|
+
* @param {object} db MongoDB database instance
|
|
7
|
+
* @param {string} collectionName Name of the collection
|
|
8
|
+
* @param {number} batchSize Number of documents to process at once
|
|
9
|
+
*/
|
|
10
|
+
static async *readCollectionBatches(db, collectionName, batchSize = 1000) {
|
|
11
|
+
const collection = db.collection(collectionName);
|
|
12
|
+
const cursor = collection.find({}).batchSize(batchSize);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
let batch = [];
|
|
16
|
+
for await (const doc of cursor) {
|
|
17
|
+
batch.push(doc);
|
|
18
|
+
if (batch.length >= batchSize) {
|
|
19
|
+
yield batch;
|
|
20
|
+
batch = [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (batch.length > 0) {
|
|
24
|
+
yield batch;
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger.error(`Error reading collection ${collectionName}`);
|
|
28
|
+
throw error;
|
|
29
|
+
} finally {
|
|
30
|
+
await cursor.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Gets a list of all collections excluding the system ones and those in the exclude list.
|
|
36
|
+
* @param {object} db MongoDB database instance
|
|
37
|
+
* @param {string[]} excludeCollections Array of collection names to exclude
|
|
38
|
+
* @returns {Promise<string[]>}
|
|
39
|
+
*/
|
|
40
|
+
static async getCollections(db, excludeCollections = []) {
|
|
41
|
+
const collections = await db.listCollections().toArray();
|
|
42
|
+
return collections
|
|
43
|
+
.map(c => c.name)
|
|
44
|
+
.filter(name => !name.startsWith('system.') && !excludeCollections.includes(name));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = CollectionReader;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { MongoClient } = require('mongodb');
|
|
2
|
+
const logger = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
class MongoConnection {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.client = null;
|
|
7
|
+
this.db = null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async connect(uri) {
|
|
11
|
+
try {
|
|
12
|
+
if (this.client) {
|
|
13
|
+
return this.db;
|
|
14
|
+
}
|
|
15
|
+
this.client = new MongoClient(uri, {
|
|
16
|
+
// Ensure we use safe reads and avoid long-running query timeouts for large backups
|
|
17
|
+
socketTimeoutMS: 300000,
|
|
18
|
+
connectTimeoutMS: 30000,
|
|
19
|
+
serverSelectionTimeoutMS: 30000
|
|
20
|
+
});
|
|
21
|
+
await this.client.connect();
|
|
22
|
+
this.db = this.client.db();
|
|
23
|
+
return this.db;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logger.error("MongoDB connection failed for Excel export.");
|
|
26
|
+
throw new Error(`MongoDB connection failed: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async disconnect() {
|
|
31
|
+
if (this.client) {
|
|
32
|
+
await this.client.close();
|
|
33
|
+
this.client = null;
|
|
34
|
+
this.db = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = MongoConnection;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const CommandRunner = require('../utils/commandRunner');
|
|
3
|
+
const logger = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
class MongoDump {
|
|
6
|
+
/**
|
|
7
|
+
* Executes mongodump.
|
|
8
|
+
* @param {string} uri MongoDB URI
|
|
9
|
+
* @param {string} outputPath Target file path (.gz)
|
|
10
|
+
* @returns {Promise<boolean>} True if successful
|
|
11
|
+
*/
|
|
12
|
+
static async execute(uri, outputPath) {
|
|
13
|
+
try {
|
|
14
|
+
logger.info("MongoDB dump started");
|
|
15
|
+
|
|
16
|
+
const args = [
|
|
17
|
+
'--uri', uri,
|
|
18
|
+
'--archive=' + outputPath,
|
|
19
|
+
'--gzip'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Run command without a shell to prevent injection
|
|
23
|
+
const result = await CommandRunner.run('mongodump', args);
|
|
24
|
+
|
|
25
|
+
// Verify file exists and has size > 0
|
|
26
|
+
if (!fs.existsSync(outputPath)) {
|
|
27
|
+
throw new Error("mongodump completed but output file is missing.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const stats = fs.statSync(outputPath);
|
|
31
|
+
if (stats.size === 0) {
|
|
32
|
+
throw new Error("mongodump created an empty file.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
logger.info("MongoDB dump completed successfully");
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.error("MongoDB dump failed", error.error || error);
|
|
39
|
+
// Don't log sensitive arguments that might contain credentials
|
|
40
|
+
throw new Error(`mongodump execution failed: ${error.error ? error.error.message : error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = MongoDump;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const exceljs = require('exceljs');
|
|
3
|
+
const MongoConnection = require('../database/mongoConnection');
|
|
4
|
+
const CollectionReader = require('../database/collectionReader');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
class ExcelExporter {
|
|
8
|
+
/**
|
|
9
|
+
* Flattens a MongoDB document for Excel export.
|
|
10
|
+
*/
|
|
11
|
+
static flattenDocument(doc) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
14
|
+
if (value === null || value === undefined) {
|
|
15
|
+
result[key] = '';
|
|
16
|
+
} else if (value instanceof Date) {
|
|
17
|
+
result[key] = value;
|
|
18
|
+
} else if (typeof value === 'object') {
|
|
19
|
+
if (value.constructor && value.constructor.name === 'ObjectId') {
|
|
20
|
+
result[key] = value.toString();
|
|
21
|
+
} else {
|
|
22
|
+
// Stringify nested objects and arrays
|
|
23
|
+
result[key] = JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
result[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sanitizes a sheet name for Excel (max 31 chars, no invalid chars).
|
|
34
|
+
*/
|
|
35
|
+
static sanitizeSheetName(name, existingNames) {
|
|
36
|
+
let safeName = name.replace(/[\\/*?:\[\]]/g, '_').substring(0, 31);
|
|
37
|
+
|
|
38
|
+
let counter = 1;
|
|
39
|
+
let finalName = safeName;
|
|
40
|
+
while (existingNames.has(finalName)) {
|
|
41
|
+
const suffix = `_${counter}`;
|
|
42
|
+
finalName = safeName.substring(0, 31 - suffix.length) + suffix;
|
|
43
|
+
counter++;
|
|
44
|
+
}
|
|
45
|
+
existingNames.add(finalName);
|
|
46
|
+
return finalName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exports all collections to an Excel file.
|
|
51
|
+
* @param {string} uri MongoDB URI
|
|
52
|
+
* @param {string} outputPath Target Excel file path
|
|
53
|
+
* @param {string[]} excludeCollections Collections to skip
|
|
54
|
+
*/
|
|
55
|
+
static async export(uri, outputPath, excludeCollections = []) {
|
|
56
|
+
logger.info("Excel export started");
|
|
57
|
+
const connection = new MongoConnection();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const db = await connection.connect(uri);
|
|
61
|
+
const collections = await CollectionReader.getCollections(db, excludeCollections);
|
|
62
|
+
|
|
63
|
+
const workbook = new exceljs.stream.xlsx.WorkbookWriter({
|
|
64
|
+
filename: outputPath,
|
|
65
|
+
useStyles: true,
|
|
66
|
+
useSharedStrings: false
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const sheetNames = new Set();
|
|
70
|
+
|
|
71
|
+
for (const collectionName of collections) {
|
|
72
|
+
const sheetName = this.sanitizeSheetName(collectionName, sheetNames);
|
|
73
|
+
const worksheet = workbook.addWorksheet(sheetName);
|
|
74
|
+
|
|
75
|
+
let isFirstBatch = true;
|
|
76
|
+
let columns = new Set();
|
|
77
|
+
|
|
78
|
+
for await (const batch of CollectionReader.readCollectionBatches(db, collectionName)) {
|
|
79
|
+
const flattenedBatch = batch.map(this.flattenDocument);
|
|
80
|
+
|
|
81
|
+
if (isFirstBatch && flattenedBatch.length > 0) {
|
|
82
|
+
// Dynamically discover columns from the first batch
|
|
83
|
+
// Note: For fully robust schema-less exports, we'd need to scan all docs,
|
|
84
|
+
// but doing it per-batch is memory efficient. We'll update columns dynamically.
|
|
85
|
+
for (const doc of flattenedBatch) {
|
|
86
|
+
Object.keys(doc).forEach(k => columns.add(k));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
worksheet.columns = Array.from(columns).map(col => ({
|
|
90
|
+
header: col,
|
|
91
|
+
key: col,
|
|
92
|
+
width: 20
|
|
93
|
+
}));
|
|
94
|
+
isFirstBatch = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// If subsequent batches have new fields, add them
|
|
98
|
+
if (!isFirstBatch) {
|
|
99
|
+
let newColsAdded = false;
|
|
100
|
+
for (const doc of flattenedBatch) {
|
|
101
|
+
Object.keys(doc).forEach(k => {
|
|
102
|
+
if (!columns.has(k)) {
|
|
103
|
+
columns.add(k);
|
|
104
|
+
newColsAdded = true;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (newColsAdded) {
|
|
109
|
+
worksheet.columns = Array.from(columns).map(col => ({
|
|
110
|
+
header: col,
|
|
111
|
+
key: col,
|
|
112
|
+
width: 20
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const doc of flattenedBatch) {
|
|
118
|
+
worksheet.addRow(doc).commit();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If collection was empty, just add a placeholder column
|
|
123
|
+
if (isFirstBatch) {
|
|
124
|
+
worksheet.columns = [{ header: 'Empty Collection', key: 'empty', width: 20 }];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
worksheet.commit();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await workbook.commit();
|
|
131
|
+
|
|
132
|
+
// Verify output
|
|
133
|
+
if (!fs.existsSync(outputPath)) {
|
|
134
|
+
throw new Error("Excel export completed but output file is missing.");
|
|
135
|
+
}
|
|
136
|
+
const stats = fs.statSync(outputPath);
|
|
137
|
+
if (stats.size === 0) {
|
|
138
|
+
throw new Error("Excel export created an empty file.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
logger.info("Excel export completed successfully");
|
|
142
|
+
return true;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.error("Excel export failed", error);
|
|
145
|
+
throw new Error(`Excel export failed: ${error.message}`);
|
|
146
|
+
} finally {
|
|
147
|
+
await connection.disconnect();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = ExcelExporter;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const configManager = require('./config/config');
|
|
2
|
+
const Scheduler = require('./scheduler/scheduler');
|
|
3
|
+
const BackupManager = require('./core/backupManager');
|
|
4
|
+
const logger = require('./utils/logger');
|
|
5
|
+
|
|
6
|
+
class DatabaseBackupService {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.scheduler = null;
|
|
9
|
+
this.backupManager = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
start(userConfig) {
|
|
13
|
+
try {
|
|
14
|
+
configManager.load(userConfig);
|
|
15
|
+
// Create BackupManager ONCE — Scheduler will share this same instance
|
|
16
|
+
// so EmailNotifier is constructed once and SMTP verify fires exactly once.
|
|
17
|
+
this.backupManager = new BackupManager();
|
|
18
|
+
this.scheduler = new Scheduler(this.backupManager);
|
|
19
|
+
this.scheduler.start();
|
|
20
|
+
logger.info("Backup service successfully started.");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logger.error("Failed to start backup service.", error);
|
|
23
|
+
// We don't rethrow to avoid crashing the consuming backend,
|
|
24
|
+
// but the service won't be running.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop() {
|
|
29
|
+
if (this.scheduler) {
|
|
30
|
+
this.scheduler.stop();
|
|
31
|
+
}
|
|
32
|
+
logger.info("Backup service stopped.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async createBackup() {
|
|
36
|
+
if (!this.backupManager) {
|
|
37
|
+
if (!configManager.get().mongoUri) {
|
|
38
|
+
throw new Error("Call backup.start(config) before backup.createBackup()");
|
|
39
|
+
}
|
|
40
|
+
this.backupManager = new BackupManager();
|
|
41
|
+
}
|
|
42
|
+
return await this.backupManager.createBackup();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getStatus(dateStr = null) {
|
|
46
|
+
if (!this.backupManager) {
|
|
47
|
+
this.backupManager = new BackupManager();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DateUtils = require('./utils/dateUtils');
|
|
51
|
+
const targetDate = dateStr || DateUtils.getCurrentDateString(configManager.get().schedule.timezone);
|
|
52
|
+
|
|
53
|
+
return this.backupManager.storage.readMetadata(targetDate);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
listBackups() {
|
|
57
|
+
if (!this.backupManager) {
|
|
58
|
+
this.backupManager = new BackupManager();
|
|
59
|
+
}
|
|
60
|
+
return this.backupManager.storage.getAllBackupDates();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async cleanup() {
|
|
64
|
+
if (!this.backupManager) {
|
|
65
|
+
this.backupManager = new BackupManager();
|
|
66
|
+
}
|
|
67
|
+
this.backupManager.retentionManager.runCleanup();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = new DatabaseBackupService();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const nodemailer = require('nodemailer');
|
|
2
|
+
const logger = require('../utils/logger');
|
|
3
|
+
const EmailTemplates = require('./emailTemplates');
|
|
4
|
+
|
|
5
|
+
class EmailNotifier {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config.email;
|
|
8
|
+
this.transporter = null;
|
|
9
|
+
|
|
10
|
+
if (!this.config.enabled) {
|
|
11
|
+
logger.info("Email notifications are disabled (email.enabled = false).");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Validate required fields before creating the transporter
|
|
16
|
+
const missing = ['host', 'user', 'password', 'to'].filter(k => !this.config[k]);
|
|
17
|
+
if (missing.length > 0) {
|
|
18
|
+
logger.warn(`Email notifications enabled but missing required config: ${missing.join(', ')}. Notifications will be skipped.`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.transporter = nodemailer.createTransport({
|
|
23
|
+
host: this.config.host,
|
|
24
|
+
port: this.config.port,
|
|
25
|
+
secure: this.config.secure, // false = STARTTLS on port 587; true = TLS on port 465
|
|
26
|
+
auth: {
|
|
27
|
+
user: this.config.user,
|
|
28
|
+
pass: this.config.password // password is never logged
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
logger.info(`Email notifier initialised. SMTP host: ${this.config.host}, port: ${this.config.port}, secure: ${this.config.secure}, recipient: ${this.config.to}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verifies SMTP connectivity. Safe to call at startup — never logs credentials.
|
|
37
|
+
* Returns true if the connection succeeds, false otherwise.
|
|
38
|
+
*/
|
|
39
|
+
async verifyConnection() {
|
|
40
|
+
if (!this.transporter) {
|
|
41
|
+
logger.warn("Email verify skipped — transporter not initialised.");
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await this.transporter.verify();
|
|
47
|
+
logger.info("SMTP connection verified successfully.");
|
|
48
|
+
return true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Log the error type and message only — nodemailer errors never include the password
|
|
51
|
+
logger.warn(`SMTP connection verification failed: ${error.code || error.message}. Check BACKUP_SMTP_HOST, BACKUP_SMTP_PORT, BACKUP_SMTP_USER, and BACKUP_SMTP_PASSWORD in your .env.`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async sendNotification(metadata) {
|
|
57
|
+
if (!this.config.enabled) return;
|
|
58
|
+
|
|
59
|
+
if (!this.transporter) {
|
|
60
|
+
logger.warn("Email notification skipped — transporter not initialised (check config warnings above).");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { subject, text } = EmailTemplates.getBackupReport(metadata);
|
|
65
|
+
|
|
66
|
+
const mailOptions = {
|
|
67
|
+
from: this.config.from || `"Database Backup Service" <${this.config.user}>`,
|
|
68
|
+
to: this.config.to,
|
|
69
|
+
subject,
|
|
70
|
+
text
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
logger.info(`Sending email notification to: ${this.config.to} | Subject: ${subject}`);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const info = await this.transporter.sendMail(mailOptions);
|
|
77
|
+
logger.info(`Email notification sent successfully. Message ID: ${info.messageId}`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Mask authentication errors — they can expose user/host but never the password via message alone
|
|
80
|
+
logger.error(`Email notification failed [${error.code || 'UNKNOWN'}]: ${error.message}`);
|
|
81
|
+
// Do NOT throw — a failed email must never degrade a successful backup result
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = EmailNotifier;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class EmailTemplates {
|
|
2
|
+
static getBackupReport(metadata) {
|
|
3
|
+
const isSuccess = metadata.status === 'success';
|
|
4
|
+
const subjectStatus = isSuccess ? 'Successful' : 'Failed';
|
|
5
|
+
|
|
6
|
+
const durationMins = Math.floor(metadata.durationMs / 60000);
|
|
7
|
+
const durationSecs = Math.floor((metadata.durationMs % 60000) / 1000);
|
|
8
|
+
const durationStr = `${durationMins} minutes ${durationSecs} seconds`;
|
|
9
|
+
|
|
10
|
+
const subject = `Database Backup ${subjectStatus} - ${metadata.projectName}`;
|
|
11
|
+
|
|
12
|
+
let text = `Subject:\nDatabase Backup ${subjectStatus} - ${metadata.projectName}\n\n`;
|
|
13
|
+
text += `Project:\n${metadata.projectName}\n\n`;
|
|
14
|
+
text += `Backup Date:\n${metadata.backupDate}\n\n`;
|
|
15
|
+
text += `Overall Status:\n${metadata.status.toUpperCase()}\n\n`;
|
|
16
|
+
|
|
17
|
+
text += `MongoDB Dump:\n${metadata.mongoDump.status.toUpperCase()}\n`;
|
|
18
|
+
if (metadata.mongoDump.file) text += `File: ${metadata.mongoDump.file}\n`;
|
|
19
|
+
if (metadata.mongoDump.error) text += `Error: ${metadata.mongoDump.error}\n`;
|
|
20
|
+
text += `\n`;
|
|
21
|
+
|
|
22
|
+
text += `Excel Export:\n${metadata.excel.status.toUpperCase()}\n`;
|
|
23
|
+
if (metadata.excel.file) text += `File: ${metadata.excel.file}\n`;
|
|
24
|
+
if (metadata.excel.error) text += `Error: ${metadata.excel.error}\n`;
|
|
25
|
+
text += `\n`;
|
|
26
|
+
|
|
27
|
+
text += `Duration:\n${durationStr}\n`;
|
|
28
|
+
|
|
29
|
+
return { subject, text };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = EmailTemplates;
|