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,44 @@
|
|
|
1
|
+
const logger = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
class RetentionManager {
|
|
4
|
+
constructor(config, storageProvider) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.storage = storageProvider;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
runCleanup() {
|
|
10
|
+
if (!this.config.retention.enabled) return;
|
|
11
|
+
|
|
12
|
+
logger.info(`Starting retention cleanup (Keeping last ${this.config.retention.days} days).`);
|
|
13
|
+
|
|
14
|
+
const allBackupDates = this.storage.getAllBackupDates();
|
|
15
|
+
|
|
16
|
+
// Sort dates descending (newest first)
|
|
17
|
+
allBackupDates.sort((a, b) => b.localeCompare(a));
|
|
18
|
+
|
|
19
|
+
let deletedCount = 0;
|
|
20
|
+
|
|
21
|
+
// Keep the first N backups
|
|
22
|
+
for (let i = 0; i < allBackupDates.length; i++) {
|
|
23
|
+
const dateStr = allBackupDates[i];
|
|
24
|
+
|
|
25
|
+
// Basic validation to ensure we only process directories that look like YYYY-MM-DD
|
|
26
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (i >= this.config.retention.days) {
|
|
31
|
+
try {
|
|
32
|
+
this.storage.deleteBackup(dateStr);
|
|
33
|
+
deletedCount++;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
logger.error(`Failed to delete old backup directory: ${dateStr}`, err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logger.info(`Retention cleanup completed. Deleted ${deletedCount} old backup(s).`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = RetentionManager;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const cron = require('node-cron');
|
|
2
|
+
const BackupManager = require('../core/backupManager');
|
|
3
|
+
const configManager = require('../config/config');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
const DateUtils = require('../utils/dateUtils');
|
|
6
|
+
|
|
7
|
+
class Scheduler {
|
|
8
|
+
/**
|
|
9
|
+
* @param {BackupManager|null} backupManager
|
|
10
|
+
* Pass the already-created BackupManager from DatabaseBackupService so
|
|
11
|
+
* only one EmailNotifier and one SMTP verify call is made per startup.
|
|
12
|
+
* If null/undefined, Scheduler creates its own (used in unit tests / standalone).
|
|
13
|
+
*/
|
|
14
|
+
constructor(backupManager = null) {
|
|
15
|
+
this.config = configManager.get();
|
|
16
|
+
this.task = null;
|
|
17
|
+
this.backupManager = backupManager || new BackupManager();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
start() {
|
|
21
|
+
if (this.task) {
|
|
22
|
+
logger.warn("Scheduler is already running.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.performStartupRecovery();
|
|
27
|
+
|
|
28
|
+
const cronExpression = this.buildCronExpression();
|
|
29
|
+
logger.info(`Starting scheduler with expression: ${cronExpression} (Timezone: ${this.config.schedule.timezone})`);
|
|
30
|
+
|
|
31
|
+
this.task = cron.schedule(cronExpression, async () => {
|
|
32
|
+
logger.info("Scheduled backup triggered.");
|
|
33
|
+
await this.backupManager.createBackup();
|
|
34
|
+
}, {
|
|
35
|
+
scheduled: true,
|
|
36
|
+
timezone: this.config.schedule.timezone
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
stop() {
|
|
41
|
+
if (this.task) {
|
|
42
|
+
this.task.stop();
|
|
43
|
+
this.task = null;
|
|
44
|
+
logger.info("Scheduler gracefully stopped.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
buildCronExpression() {
|
|
49
|
+
const { type, time, day } = this.config.schedule;
|
|
50
|
+
// Split correctly: "HH:MM" -> [HH, MM]
|
|
51
|
+
const [hourStr, minuteStr] = time.split(':');
|
|
52
|
+
// cron format is: minute hour day month weekday
|
|
53
|
+
switch (type.toLowerCase()) {
|
|
54
|
+
case 'daily':
|
|
55
|
+
return `${minuteStr} ${hourStr} * * *`;
|
|
56
|
+
case 'weekly': {
|
|
57
|
+
const dayMap = { 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, 'thursday': 4, 'friday': 5, 'saturday': 6 };
|
|
58
|
+
if (!day || dayMap[day.toLowerCase()] === undefined) throw new Error("Invalid or missing 'day' for weekly schedule. Expected: sunday..saturday");
|
|
59
|
+
const dayOfWeek = dayMap[day.toLowerCase()];
|
|
60
|
+
return `${minuteStr} ${hourStr} * * ${dayOfWeek}`;
|
|
61
|
+
}
|
|
62
|
+
case 'monthly': {
|
|
63
|
+
if (!day || day < 1 || day > 28) throw new Error("Invalid or missing 'day' for monthly schedule. Expected: 1-28");
|
|
64
|
+
return `${minuteStr} ${hourStr} ${day} * *`;
|
|
65
|
+
}
|
|
66
|
+
default:
|
|
67
|
+
throw new Error(`Unsupported schedule type: ${type}. Expected: daily, weekly, monthly`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async performStartupRecovery() {
|
|
72
|
+
logger.info("Checking for missed backups on startup...");
|
|
73
|
+
const expectedDateStr = this.getMostRecentExpectedBackupDate();
|
|
74
|
+
|
|
75
|
+
if (expectedDateStr) {
|
|
76
|
+
const storage = this.backupManager.storage;
|
|
77
|
+
const existingMetadata = storage.readMetadata(expectedDateStr);
|
|
78
|
+
|
|
79
|
+
if (!existingMetadata || existingMetadata.status !== 'success') {
|
|
80
|
+
logger.info(`Missed or failed backup detected for expected date: ${expectedDateStr}. Initiating recovery backup.`);
|
|
81
|
+
// Do not await this. Let it run in the background so it doesn't block startup.
|
|
82
|
+
this.backupManager.createBackup(expectedDateStr).catch(err => {
|
|
83
|
+
logger.error("Startup recovery backup failed.", err);
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
logger.info(`Most recent expected backup (${expectedDateStr}) is already successful. No recovery needed.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getMostRecentExpectedBackupDate() {
|
|
92
|
+
const tz = this.config.schedule.timezone;
|
|
93
|
+
const { type, time, day } = this.config.schedule;
|
|
94
|
+
const [hourStr, minuteStr] = time.split(':');
|
|
95
|
+
const schedHour = parseInt(hourStr, 10);
|
|
96
|
+
const schedMin = parseInt(minuteStr, 10);
|
|
97
|
+
|
|
98
|
+
// Get the current time parts in the configured timezone (no moment.js needed)
|
|
99
|
+
const now = new Date();
|
|
100
|
+
const tzParts = new Intl.DateTimeFormat('en-US', {
|
|
101
|
+
timeZone: tz,
|
|
102
|
+
hour: 'numeric', minute: 'numeric', hour12: false,
|
|
103
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
104
|
+
weekday: 'long'
|
|
105
|
+
}).formatToParts(now);
|
|
106
|
+
|
|
107
|
+
const get = (type) => tzParts.find(p => p.type === type);
|
|
108
|
+
const tzHour = parseInt(get('hour').value, 10);
|
|
109
|
+
const tzMinute = parseInt(get('minute').value, 10);
|
|
110
|
+
const tzDay = parseInt(get('day').value, 10);
|
|
111
|
+
const tzWeekday = get('weekday').value.toLowerCase(); // e.g. 'wednesday'
|
|
112
|
+
|
|
113
|
+
const currentDateStr = DateUtils.getCurrentDateString(tz);
|
|
114
|
+
|
|
115
|
+
// Has the scheduled time already passed today (in configured timezone)?
|
|
116
|
+
const passedToday = (tzHour > schedHour) || (tzHour === schedHour && tzMinute >= schedMin);
|
|
117
|
+
|
|
118
|
+
if (type.toLowerCase() === 'daily') {
|
|
119
|
+
if (passedToday) return currentDateStr;
|
|
120
|
+
// Scheduled time hasn't arrived yet today — yesterday's backup is the expected one
|
|
121
|
+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
122
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(yesterday);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (type.toLowerCase() === 'weekly') {
|
|
126
|
+
// Check if today is the scheduled weekday AND the time has passed
|
|
127
|
+
const scheduledWeekday = (day || '').toLowerCase();
|
|
128
|
+
if (tzWeekday === scheduledWeekday && passedToday) {
|
|
129
|
+
return currentDateStr;
|
|
130
|
+
}
|
|
131
|
+
// Otherwise: find the most recent occurrence of that weekday
|
|
132
|
+
const dayMap = { 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, 'thursday': 4, 'friday': 5, 'saturday': 6 };
|
|
133
|
+
const targetDow = dayMap[scheduledWeekday];
|
|
134
|
+
const currentDow = dayMap[tzWeekday];
|
|
135
|
+
if (targetDow === undefined) return null;
|
|
136
|
+
let daysBack = (currentDow - targetDow + 7) % 7;
|
|
137
|
+
if (daysBack === 0) daysBack = 7; // same weekday but time hasn't passed
|
|
138
|
+
const prevDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);
|
|
139
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(prevDate);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (type.toLowerCase() === 'monthly') {
|
|
143
|
+
const scheduledDayOfMonth = parseInt(day, 10);
|
|
144
|
+
if (tzDay === scheduledDayOfMonth && passedToday) {
|
|
145
|
+
return currentDateStr;
|
|
146
|
+
}
|
|
147
|
+
// Otherwise: check the previous month's occurrence
|
|
148
|
+
// Go back to the 1st of this month, then subtract one day to get last month, then format day
|
|
149
|
+
const prevMonthDate = new Date(now);
|
|
150
|
+
prevMonthDate.setDate(0); // last day of previous month
|
|
151
|
+
const prevMonthParts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit' }).formatToParts(prevMonthDate);
|
|
152
|
+
const prevYear = prevMonthParts.find(p => p.type === 'year').value;
|
|
153
|
+
const prevMonth = prevMonthParts.find(p => p.type === 'month').value;
|
|
154
|
+
const dayPadded = String(scheduledDayOfMonth).padStart(2, '0');
|
|
155
|
+
return `${prevYear}-${prevMonth}-${dayPadded}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = Scheduler;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const FileManager = require('../utils/fileManager');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
class LocalStorage {
|
|
7
|
+
constructor(basePath) {
|
|
8
|
+
this.basePath = path.resolve(basePath);
|
|
9
|
+
FileManager.ensureDirectoryExists(this.basePath);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getBackupDirectory(dateStr) {
|
|
13
|
+
return path.join(this.basePath, dateStr);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ensureBackupDirectory(dateStr) {
|
|
17
|
+
const dir = this.getBackupDirectory(dateStr);
|
|
18
|
+
FileManager.ensureDirectoryExists(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getMetadataPath(dateStr) {
|
|
23
|
+
return path.join(this.getBackupDirectory(dateStr), 'metadata.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
writeMetadata(dateStr, metadata) {
|
|
27
|
+
const filePath = this.getMetadataPath(dateStr);
|
|
28
|
+
fs.writeFileSync(filePath, JSON.stringify(metadata, null, 4), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
readMetadata(dateStr) {
|
|
32
|
+
const filePath = this.getMetadataPath(dateStr);
|
|
33
|
+
if (fs.existsSync(filePath)) {
|
|
34
|
+
try {
|
|
35
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
36
|
+
return JSON.parse(data);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.error(`Failed to parse metadata for ${dateStr}`, err);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getAllBackupDates() {
|
|
46
|
+
return FileManager.getDirectories(this.basePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
deleteBackup(dateStr) {
|
|
50
|
+
const dir = this.getBackupDirectory(dateStr);
|
|
51
|
+
FileManager.deleteDirectory(dir);
|
|
52
|
+
logger.info(`Deleted backup directory: ${dir}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = LocalStorage;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const LocalStorage = require('./localStorage');
|
|
2
|
+
|
|
3
|
+
class StorageManager {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
// V1 uses local storage only. Future logic can instantiate cloud providers here.
|
|
6
|
+
this.provider = new LocalStorage(config.storage.path);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
getProvider() {
|
|
10
|
+
return this.provider;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = StorageManager;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const logger = require('./logger');
|
|
3
|
+
|
|
4
|
+
class CommandRunner {
|
|
5
|
+
/**
|
|
6
|
+
* Executes a command safely using spawn.
|
|
7
|
+
* @param {string} command
|
|
8
|
+
* @param {string[]} args
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
11
|
+
*/
|
|
12
|
+
static run(command, args, options = {}) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(command, args, { shell: false, ...options });
|
|
15
|
+
|
|
16
|
+
let stdout = '';
|
|
17
|
+
let stderr = '';
|
|
18
|
+
|
|
19
|
+
child.stdout.on('data', (data) => {
|
|
20
|
+
stdout += data.toString();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
child.stderr.on('data', (data) => {
|
|
24
|
+
stderr += data.toString();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.on('error', (error) => {
|
|
28
|
+
reject({ error, stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
child.on('close', (code) => {
|
|
32
|
+
if (code === 0) {
|
|
33
|
+
resolve({ stdout, stderr });
|
|
34
|
+
} else {
|
|
35
|
+
reject({ error: new Error(`Command failed with code ${code}`), code, stdout, stderr });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = CommandRunner;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class DateUtils {
|
|
2
|
+
/**
|
|
3
|
+
* Get the current date string based on the given timezone.
|
|
4
|
+
* @param {string} timezone
|
|
5
|
+
* @returns {string} YYYY-MM-DD
|
|
6
|
+
*/
|
|
7
|
+
static getCurrentDateString(timezone = 'UTC') {
|
|
8
|
+
const options = { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' };
|
|
9
|
+
const formatter = new Intl.DateTimeFormat('en-CA', options); // en-CA gives YYYY-MM-DD
|
|
10
|
+
return formatter.format(new Date());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static getIsoString() {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = DateUtils;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class FileManager {
|
|
5
|
+
static ensureDirectoryExists(dirPath) {
|
|
6
|
+
if (!fs.existsSync(dirPath)) {
|
|
7
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static deleteDirectory(dirPath) {
|
|
12
|
+
if (fs.existsSync(dirPath)) {
|
|
13
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static getDirectories(srcPath) {
|
|
18
|
+
if (!fs.existsSync(srcPath)) return [];
|
|
19
|
+
return fs.readdirSync(srcPath)
|
|
20
|
+
.filter(file => fs.statSync(path.join(srcPath, file)).isDirectory());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = FileManager;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class Logger {
|
|
2
|
+
formatMessage(level, message) {
|
|
3
|
+
const timestamp = new Date().toISOString();
|
|
4
|
+
return `[${timestamp}] [BACKUP ${level}] ${message}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
info(message) {
|
|
8
|
+
console.log(this.formatMessage('INFO', message));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
warn(message) {
|
|
12
|
+
console.warn(this.formatMessage('WARN', message));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
error(message, err = null) {
|
|
16
|
+
let msg = this.formatMessage('ERROR', message);
|
|
17
|
+
if (err) {
|
|
18
|
+
msg += `\n${err.stack || err.message || err}`;
|
|
19
|
+
}
|
|
20
|
+
console.error(msg);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = new Logger();
|