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:
Ulas Kalayci
2026-05-04 07:02:38 +02:00
parent 99a2280c02
commit 9b29d1847c
11 changed files with 484 additions and 3 deletions
+4
View File
@@ -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;
+13
View File
@@ -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 });
+189
View File
@@ -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;
}