feat: automatische geplante Backups mit Rotation
Phase 1.3 - Automatische Backups: - Cron-basierter Scheduler (Standard: täglich 2 Uhr) - Konfigurierbar über .env (Zeitplan, Verzeichnis, Anzahl) - Automatische Rotation: behält nur letzte N Backups (Standard: 7) - UI in Settings → Backup: Status-Anzeige und manueller Trigger - Tests: 7 erfolgreiche Tests für Scheduler-Funktionalität Neue Umgebungsvariablen: - BACKUP_ENABLED (Standard: true) - BACKUP_SCHEDULE (Standard: 0 2 * * *) - BACKUP_DIR (Standard: ./backups) - BACKUP_KEEP (Standard: 7) - TZ (für Zeitzone) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { buildOpenApiSpec } from './openapi.js';
|
||||
import * as googleCalendar from './services/google-calendar.js';
|
||||
import * as appleCalendar from './services/apple-calendar.js';
|
||||
import * as icsSubscription from './services/ics-subscription.js';
|
||||
import { startScheduler as startBackupScheduler } from './services/backup-scheduler.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import tasksRouter from './routes/tasks.js';
|
||||
import shoppingRouter from './routes/shopping.js';
|
||||
@@ -274,6 +275,9 @@ app.listen(PORT, () => {
|
||||
setInterval(runSync, SYNC_INTERVAL_MS);
|
||||
logSync.info(`Auto-sync active every ${SYNC_INTERVAL_MS / 60_000} minutes.`);
|
||||
}, 10_000);
|
||||
|
||||
// Backup-Scheduler starten
|
||||
startBackupScheduler();
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -11,6 +11,7 @@ import fs from 'node:fs/promises';
|
||||
import { backupToFile, currentVersion, restoreFromFile } from '../db.js';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { getStatus as getSchedulerStatus, triggerBackup } from '../services/backup-scheduler.js';
|
||||
|
||||
const router = express.Router();
|
||||
const log = createLogger('Backup');
|
||||
@@ -22,10 +23,12 @@ function backupFileName() {
|
||||
}
|
||||
|
||||
router.get('/status', requireAdmin, (req, res) => {
|
||||
const schedulerStatus = getSchedulerStatus();
|
||||
res.json({
|
||||
data: {
|
||||
schema_version: currentVersion(),
|
||||
restore_upload_limit: RESTORE_LIMIT,
|
||||
scheduler: schedulerStatus,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -87,6 +90,16 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/trigger', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await triggerBackup();
|
||||
res.json({ data: result });
|
||||
} catch (err) {
|
||||
log.error('Manual backup trigger failed:', err);
|
||||
res.status(500).json({ error: 'Backup trigger failed.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
router.use((err, req, res, next) => {
|
||||
if (err?.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: `Backup file is too large. Maximum upload size is ${RESTORE_LIMIT}.`, code: 413 });
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Module: Backup Scheduler
|
||||
* Purpose: Automated scheduled database backups with rotation
|
||||
* Dependencies: node-cron, fs/promises, path, server/db.js
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { backupToFile } from '../db.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const log = createLogger('BackupScheduler');
|
||||
|
||||
// Configuration from environment variables
|
||||
const BACKUP_SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'; // Default: 2 AM daily
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
||||
const BACKUP_KEEP = parseInt(process.env.BACKUP_KEEP || '7', 10); // Default: keep last 7 backups
|
||||
const BACKUP_ENABLED = process.env.BACKUP_ENABLED !== 'false'; // Default: enabled
|
||||
|
||||
let scheduledTask = null;
|
||||
let lastBackup = null;
|
||||
let lastError = null;
|
||||
|
||||
/**
|
||||
* Generate timestamped backup filename
|
||||
*/
|
||||
function backupFileName() {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
return `oikos-backup-${stamp}.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure backup directory exists
|
||||
*/
|
||||
async function ensureBackupDir() {
|
||||
try {
|
||||
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
||||
} catch (err) {
|
||||
log.error('Failed to create backup directory:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backup files sorted by modification time (newest first)
|
||||
*/
|
||||
async function getBackupFiles() {
|
||||
try {
|
||||
const files = await fs.readdir(BACKUP_DIR);
|
||||
const backupFiles = files.filter((f) => f.startsWith('oikos-backup-') && f.endsWith('.db'));
|
||||
|
||||
// Get file stats and sort by modification time
|
||||
const filesWithStats = await Promise.all(
|
||||
backupFiles.map(async (file) => {
|
||||
const filePath = path.join(BACKUP_DIR, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
return { file, mtime: stats.mtime, path: filePath };
|
||||
})
|
||||
);
|
||||
|
||||
return filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate backups - keep only the last N backups
|
||||
*/
|
||||
async function rotateBackups() {
|
||||
try {
|
||||
const files = await getBackupFiles();
|
||||
|
||||
if (files.length <= BACKUP_KEEP) {
|
||||
return; // Nothing to delete
|
||||
}
|
||||
|
||||
const filesToDelete = files.slice(BACKUP_KEEP);
|
||||
|
||||
for (const { file, path: filePath } of filesToDelete) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
log.info(`Rotated old backup: ${file}`);
|
||||
} catch (err) {
|
||||
log.error(`Failed to delete old backup ${file}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Backup rotation failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform automated backup
|
||||
*/
|
||||
async function performBackup() {
|
||||
try {
|
||||
log.info('Starting scheduled backup...');
|
||||
|
||||
await ensureBackupDir();
|
||||
|
||||
const fileName = backupFileName();
|
||||
const filePath = path.join(BACKUP_DIR, fileName);
|
||||
|
||||
await backupToFile(filePath);
|
||||
|
||||
log.info(`Backup created: ${fileName}`);
|
||||
|
||||
// Rotate old backups
|
||||
await rotateBackups();
|
||||
|
||||
lastBackup = {
|
||||
timestamp: new Date().toISOString(),
|
||||
file: fileName,
|
||||
success: true,
|
||||
};
|
||||
lastError = null;
|
||||
} catch (err) {
|
||||
log.error('Scheduled backup failed:', err);
|
||||
lastError = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: err.message,
|
||||
};
|
||||
lastBackup = {
|
||||
timestamp: new Date().toISOString(),
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backup scheduler
|
||||
*/
|
||||
export function startScheduler() {
|
||||
if (!BACKUP_ENABLED) {
|
||||
log.info('Automated backups are disabled (BACKUP_ENABLED=false)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(BACKUP_SCHEDULE)) {
|
||||
log.error(`Invalid cron schedule: ${BACKUP_SCHEDULE}`);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledTask = cron.schedule(BACKUP_SCHEDULE, performBackup, {
|
||||
timezone: process.env.TZ || 'UTC',
|
||||
});
|
||||
|
||||
log.info(`Backup scheduler started: ${BACKUP_SCHEDULE} (keeping last ${BACKUP_KEEP} backups)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the backup scheduler
|
||||
*/
|
||||
export function stopScheduler() {
|
||||
if (scheduledTask) {
|
||||
scheduledTask.stop();
|
||||
scheduledTask = null;
|
||||
log.info('Backup scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
*/
|
||||
export function getStatus() {
|
||||
return {
|
||||
enabled: BACKUP_ENABLED,
|
||||
schedule: BACKUP_SCHEDULE,
|
||||
backupDir: BACKUP_DIR,
|
||||
keepCount: BACKUP_KEEP,
|
||||
running: scheduledTask !== null,
|
||||
lastBackup,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate backup (for manual/testing purposes)
|
||||
*/
|
||||
export async function triggerBackup() {
|
||||
await performBackup();
|
||||
return lastBackup;
|
||||
}
|
||||
Reference in New Issue
Block a user