feat(backup): Add backup scheduling system with GFS retention, API and UI integration
This commit is contained in:
@@ -22,6 +22,8 @@ import type {
|
||||
IRestoreResult,
|
||||
TPlatformServiceType,
|
||||
IPlatformResource,
|
||||
IBackupCreateOptions,
|
||||
TRetentionTier,
|
||||
} from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
@@ -45,8 +47,10 @@ export class BackupManager {
|
||||
|
||||
/**
|
||||
* Create a backup for a service
|
||||
* @param serviceName - Name of the service to backup
|
||||
* @param options - Optional backup creation options (scheduleId)
|
||||
*/
|
||||
async createBackup(serviceName: string): Promise<IBackupResult> {
|
||||
async createBackup(serviceName: string, options?: IBackupCreateOptions): Promise<IBackupResult> {
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${serviceName}`);
|
||||
@@ -181,6 +185,7 @@ export class BackupManager {
|
||||
includesImage: includeImage,
|
||||
platformResources: resourceTypes,
|
||||
checksum,
|
||||
scheduleId: options?.scheduleId,
|
||||
};
|
||||
|
||||
const createdBackup = this.oneboxRef.database.createBackup(backup);
|
||||
|
||||
650
ts/classes/backup-scheduler.ts
Normal file
650
ts/classes/backup-scheduler.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* Backup Scheduler for Onebox
|
||||
*
|
||||
* Uses @push.rocks/taskbuffer for cron-based scheduled backups
|
||||
* with GFS (Grandfather-Father-Son) time-window based retention scheme.
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IBackupSchedule,
|
||||
IBackupScheduleCreate,
|
||||
IBackupScheduleUpdate,
|
||||
IService,
|
||||
IRetentionPolicy,
|
||||
} from '../types.ts';
|
||||
import { RETENTION_PRESETS } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
|
||||
export class BackupScheduler {
|
||||
private oneboxRef: Onebox;
|
||||
private taskManager!: plugins.taskbuffer.TaskManager;
|
||||
private scheduledTasks: Map<number, plugins.taskbuffer.Task> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
// TaskManager is created in init() to avoid log spam before ready
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the scheduler and load enabled schedules
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
logger.warn('BackupScheduler already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam
|
||||
this.taskManager = new plugins.taskbuffer.TaskManager();
|
||||
|
||||
// Add heartbeat task immediately to prevent "no cronjobs specified" log spam
|
||||
// This runs hourly and does nothing, but keeps taskbuffer happy
|
||||
const heartbeatTask = new plugins.taskbuffer.Task({
|
||||
name: 'backup-scheduler-heartbeat',
|
||||
taskFunction: async () => {
|
||||
// No-op heartbeat task
|
||||
},
|
||||
});
|
||||
this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly
|
||||
|
||||
// Load all enabled schedules from database
|
||||
const schedules = this.oneboxRef.database.getEnabledBackupSchedules();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
await this.registerTask(schedule);
|
||||
}
|
||||
|
||||
// Start the task manager (activates cron scheduling)
|
||||
await this.taskManager.start();
|
||||
|
||||
this.initialized = true;
|
||||
logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.initialized || !this.taskManager) return;
|
||||
|
||||
try {
|
||||
await this.taskManager.stop();
|
||||
this.scheduledTasks.clear();
|
||||
this.initialized = false;
|
||||
logger.info('Backup scheduler stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new backup schedule
|
||||
*/
|
||||
async createSchedule(request: IBackupScheduleCreate): Promise<IBackupSchedule> {
|
||||
// Validate based on scope type
|
||||
let serviceId: number | undefined;
|
||||
let serviceName: string | undefined;
|
||||
|
||||
switch (request.scopeType) {
|
||||
case 'service':
|
||||
// Validate service exists
|
||||
if (!request.serviceName) {
|
||||
throw new Error('serviceName is required for service-specific schedules');
|
||||
}
|
||||
const service = this.oneboxRef.database.getServiceByName(request.serviceName);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${request.serviceName}`);
|
||||
}
|
||||
serviceId = service.id!;
|
||||
serviceName = service.name;
|
||||
break;
|
||||
|
||||
case 'pattern':
|
||||
// Validate pattern is provided
|
||||
if (!request.scopePattern) {
|
||||
throw new Error('scopePattern is required for pattern-based schedules');
|
||||
}
|
||||
// Validate pattern matches at least one service
|
||||
const matchingServices = this.getServicesMatchingPattern(request.scopePattern);
|
||||
if (matchingServices.length === 0) {
|
||||
logger.warn(`Pattern "${request.scopePattern}" currently matches no services`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
// No validation needed for global schedules
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid scope type: ${request.scopeType}`);
|
||||
}
|
||||
|
||||
// Use provided cron expression
|
||||
const cronExpression = request.cronExpression;
|
||||
|
||||
// Calculate next run time
|
||||
const nextRunAt = this.calculateNextRun(cronExpression);
|
||||
|
||||
// Create schedule in database
|
||||
const schedule = this.oneboxRef.database.createBackupSchedule({
|
||||
scopeType: request.scopeType,
|
||||
scopePattern: request.scopePattern,
|
||||
serviceId,
|
||||
serviceName,
|
||||
cronExpression,
|
||||
retention: request.retention,
|
||||
enabled: request.enabled !== false,
|
||||
lastRunAt: null,
|
||||
nextRunAt,
|
||||
lastStatus: null,
|
||||
lastError: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Register task if enabled
|
||||
if (schedule.enabled) {
|
||||
await this.registerTask(schedule);
|
||||
}
|
||||
|
||||
const scopeDesc = this.getScopeDescription(schedule);
|
||||
const retentionDesc = this.getRetentionDescription(schedule.retention);
|
||||
logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing backup schedule
|
||||
*/
|
||||
async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise<IBackupSchedule> {
|
||||
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||
}
|
||||
|
||||
// Deschedule existing task if present
|
||||
await this.descheduleTask(scheduleId);
|
||||
|
||||
// Update database
|
||||
this.oneboxRef.database.updateBackupSchedule(scheduleId, updates);
|
||||
|
||||
// Get updated schedule
|
||||
const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
|
||||
|
||||
// Calculate new next run time if cron changed
|
||||
if (updates.cronExpression) {
|
||||
const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression);
|
||||
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
|
||||
}
|
||||
|
||||
// Re-register task if enabled
|
||||
if (updatedSchedule.enabled) {
|
||||
await this.registerTask(updatedSchedule);
|
||||
}
|
||||
|
||||
logger.info(`Backup schedule updated: ${scheduleId}`);
|
||||
return this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
async deleteSchedule(scheduleId: number): Promise<void> {
|
||||
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||
}
|
||||
|
||||
// Deschedule task
|
||||
await this.descheduleTask(scheduleId);
|
||||
|
||||
// Delete from database
|
||||
this.oneboxRef.database.deleteBackupSchedule(scheduleId);
|
||||
|
||||
logger.info(`Backup schedule deleted: ${scheduleId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate backup for a schedule
|
||||
*/
|
||||
async triggerBackup(scheduleId: number): Promise<void> {
|
||||
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||
}
|
||||
|
||||
logger.info(`Manually triggering backup for schedule ${scheduleId}`);
|
||||
await this.executeBackup(schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all schedules
|
||||
*/
|
||||
getAllSchedules(): IBackupSchedule[] {
|
||||
return this.oneboxRef.database.getAllBackupSchedules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedule by ID
|
||||
*/
|
||||
getScheduleById(id: number): IBackupSchedule | null {
|
||||
return this.oneboxRef.database.getBackupScheduleById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedules for a service
|
||||
*/
|
||||
getSchedulesForService(serviceName: string): IBackupSchedule[] {
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
return [];
|
||||
}
|
||||
return this.oneboxRef.database.getBackupSchedulesByService(service.id!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retention presets
|
||||
*/
|
||||
getRetentionPresets(): typeof RETENTION_PRESETS {
|
||||
return RETENTION_PRESETS;
|
||||
}
|
||||
|
||||
// ========== Private Methods ==========
|
||||
|
||||
/**
|
||||
* Register a task for a schedule
|
||||
*/
|
||||
private async registerTask(schedule: IBackupSchedule): Promise<void> {
|
||||
const taskName = `backup-${schedule.id}`;
|
||||
|
||||
const task = new plugins.taskbuffer.Task({
|
||||
name: taskName,
|
||||
taskFunction: async () => {
|
||||
await this.executeBackup(schedule);
|
||||
},
|
||||
});
|
||||
|
||||
// Add and schedule the task
|
||||
this.taskManager.addAndScheduleTask(task, schedule.cronExpression);
|
||||
this.scheduledTasks.set(schedule.id!, task);
|
||||
|
||||
// Update next run time in database
|
||||
this.updateNextRunTime(schedule.id!);
|
||||
|
||||
logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deschedule a task
|
||||
*/
|
||||
private async descheduleTask(scheduleId: number): Promise<void> {
|
||||
const task = this.scheduledTasks.get(scheduleId);
|
||||
if (task) {
|
||||
await this.taskManager.descheduleTask(task);
|
||||
this.scheduledTasks.delete(scheduleId);
|
||||
logger.debug(`Descheduled backup task for schedule ${scheduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a backup for a schedule
|
||||
*/
|
||||
private async executeBackup(schedule: IBackupSchedule): Promise<void> {
|
||||
const scopeDesc = this.getScopeDescription(schedule);
|
||||
const servicesToBackup = this.getServicesForSchedule(schedule);
|
||||
|
||||
if (servicesToBackup.length === 0) {
|
||||
logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`);
|
||||
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
|
||||
lastRunAt: Date.now(),
|
||||
lastStatus: 'success',
|
||||
lastError: 'No matching services found',
|
||||
});
|
||||
this.updateNextRunTime(schedule.id!);
|
||||
return;
|
||||
}
|
||||
|
||||
const retentionDesc = this.getRetentionDescription(schedule.retention);
|
||||
logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const service of servicesToBackup) {
|
||||
try {
|
||||
// Create backup with schedule ID
|
||||
await this.oneboxRef.backupManager.createBackup(service.name, {
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
|
||||
// Apply time-window based retention policy for this service
|
||||
await this.applyRetention(schedule, service.id!);
|
||||
|
||||
successCount++;
|
||||
logger.success(`Scheduled backup completed for ${service.name}`);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`);
|
||||
errors.push(`${service.name}: ${errorMessage}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update schedule status
|
||||
const lastStatus = failCount === 0 ? 'success' : 'failed';
|
||||
const lastError = errors.length > 0 ? errors.join('; ') : null;
|
||||
|
||||
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
|
||||
lastRunAt: Date.now(),
|
||||
lastStatus,
|
||||
lastError,
|
||||
});
|
||||
|
||||
if (failCount === 0) {
|
||||
logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`);
|
||||
} else {
|
||||
logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
|
||||
// Update next run time
|
||||
this.updateNextRunTime(schedule.id!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply time-window based retention policy
|
||||
* Works correctly regardless of backup frequency (cron schedule)
|
||||
*/
|
||||
private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise<void> {
|
||||
// Get all backups for this schedule and service
|
||||
const allBackups = this.oneboxRef.database.getBackupsByService(serviceId);
|
||||
const backups = allBackups.filter(b => b.scheduleId === schedule.id);
|
||||
|
||||
if (backups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hourly, daily, weekly, monthly } = schedule.retention;
|
||||
const now = Date.now();
|
||||
const toKeep = new Set<number>();
|
||||
|
||||
// Hourly: Keep up to N most recent backups from last 24 hours
|
||||
if (hourly > 0) {
|
||||
const recentBackups = backups
|
||||
.filter(b => now - b.createdAt < 24 * 60 * 60 * 1000)
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.slice(0, hourly);
|
||||
recentBackups.forEach(b => toKeep.add(b.id!));
|
||||
}
|
||||
|
||||
// Daily: Keep oldest backup per day for last N days
|
||||
if (daily > 0) {
|
||||
for (let i = 0; i < daily; i++) {
|
||||
const dayStart = this.getStartOfDay(now, i);
|
||||
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
||||
const dayBackups = backups.filter(b =>
|
||||
b.createdAt >= dayStart && b.createdAt < dayEnd
|
||||
);
|
||||
if (dayBackups.length > 0) {
|
||||
// Keep oldest from this day (most representative)
|
||||
const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
toKeep.add(oldest.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly: Keep oldest backup per week for last N weeks
|
||||
if (weekly > 0) {
|
||||
for (let i = 0; i < weekly; i++) {
|
||||
const weekStart = this.getStartOfWeek(now, i);
|
||||
const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
|
||||
const weekBackups = backups.filter(b =>
|
||||
b.createdAt >= weekStart && b.createdAt < weekEnd
|
||||
);
|
||||
if (weekBackups.length > 0) {
|
||||
const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
toKeep.add(oldest.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monthly: Keep oldest backup per month for last N months
|
||||
if (monthly > 0) {
|
||||
for (let i = 0; i < monthly; i++) {
|
||||
const { start, end } = this.getMonthRange(now, i);
|
||||
const monthBackups = backups.filter(b =>
|
||||
b.createdAt >= start && b.createdAt < end
|
||||
);
|
||||
if (monthBackups.length > 0) {
|
||||
const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
toKeep.add(oldest.id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete anything not in toKeep
|
||||
for (const backup of backups) {
|
||||
if (!toKeep.has(backup.id!)) {
|
||||
try {
|
||||
await this.oneboxRef.backupManager.deleteBackup(backup.id!);
|
||||
logger.info(`Deleted backup ${backup.filename} (retention policy)`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of day (midnight) for N days ago
|
||||
*/
|
||||
private getStartOfDay(now: number, daysAgo: number): number {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of week (Sunday midnight) for N weeks ago
|
||||
*/
|
||||
private getStartOfWeek(now: number, weeksAgo: number): number {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay());
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month range for N months ago
|
||||
*/
|
||||
private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() - monthsAgo);
|
||||
date.setDate(1);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const start = date.getTime();
|
||||
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
const end = date.getTime();
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update next run time for a schedule
|
||||
*/
|
||||
private updateNextRunTime(scheduleId: number): void {
|
||||
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||
if (!schedule) return;
|
||||
|
||||
const nextRunAt = this.calculateNextRun(schedule.cronExpression);
|
||||
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next run time from cron expression
|
||||
*/
|
||||
private calculateNextRun(cronExpression: string): number {
|
||||
try {
|
||||
// Get next scheduled runs from task manager
|
||||
const scheduledTasks = this.taskManager.getScheduledTasks();
|
||||
|
||||
// Find our task and get its next run
|
||||
for (const taskInfo of scheduledTasks) {
|
||||
if (taskInfo.schedule === cronExpression && taskInfo.nextRun) {
|
||||
return taskInfo.nextRun.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse cron and calculate next occurrence
|
||||
// Simple implementation for common patterns
|
||||
const now = new Date();
|
||||
const parts = cronExpression.split(' ');
|
||||
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
// For daily schedules (e.g., "0 2 * * *")
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
const nextRun = new Date(now);
|
||||
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
if (nextRun <= now) {
|
||||
nextRun.setDate(nextRun.getDate() + 1);
|
||||
}
|
||||
return nextRun.getTime();
|
||||
}
|
||||
|
||||
// For weekly schedules (e.g., "0 2 * * 0")
|
||||
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
||||
const targetDay = parseInt(dayOfWeek);
|
||||
const nextRun = new Date(now);
|
||||
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
const currentDay = now.getDay();
|
||||
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||
if (daysUntilTarget === 0 && nextRun <= now) {
|
||||
daysUntilTarget = 7;
|
||||
}
|
||||
nextRun.setDate(nextRun.getDate() + daysUntilTarget);
|
||||
return nextRun.getTime();
|
||||
}
|
||||
|
||||
// For monthly schedules (e.g., "0 2 1 * *")
|
||||
if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
|
||||
const targetDay = parseInt(dayOfMonth);
|
||||
const nextRun = new Date(now);
|
||||
nextRun.setDate(targetDay);
|
||||
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
if (nextRun <= now) {
|
||||
nextRun.setMonth(nextRun.getMonth() + 1);
|
||||
}
|
||||
return nextRun.getTime();
|
||||
}
|
||||
|
||||
// For yearly schedules (e.g., "0 2 1 1 *")
|
||||
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
|
||||
const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed
|
||||
const targetDay = parseInt(dayOfMonth);
|
||||
const nextRun = new Date(now);
|
||||
nextRun.setMonth(targetMonth, targetDay);
|
||||
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||
if (nextRun <= now) {
|
||||
nextRun.setFullYear(nextRun.getFullYear() + 1);
|
||||
}
|
||||
return nextRun.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
// Default: next day at 2 AM
|
||||
const fallback = new Date(now);
|
||||
fallback.setDate(fallback.getDate() + 1);
|
||||
fallback.setHours(2, 0, 0, 0);
|
||||
return fallback.getTime();
|
||||
} catch {
|
||||
// On any error, return tomorrow at 2 AM
|
||||
const fallback = new Date();
|
||||
fallback.setDate(fallback.getDate() + 1);
|
||||
fallback.setHours(2, 0, 0, 0);
|
||||
return fallback.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services that match a schedule based on its scope type
|
||||
*/
|
||||
private getServicesForSchedule(schedule: IBackupSchedule): IService[] {
|
||||
const allServices = this.oneboxRef.database.getAllServices();
|
||||
|
||||
switch (schedule.scopeType) {
|
||||
case 'all':
|
||||
return allServices;
|
||||
|
||||
case 'pattern':
|
||||
if (!schedule.scopePattern) return [];
|
||||
return this.getServicesMatchingPattern(schedule.scopePattern);
|
||||
|
||||
case 'service':
|
||||
if (!schedule.serviceId) return [];
|
||||
const service = allServices.find(s => s.id === schedule.serviceId);
|
||||
return service ? [service] : [];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get services that match a glob pattern
|
||||
*/
|
||||
private getServicesMatchingPattern(pattern: string): IService[] {
|
||||
const allServices = this.oneboxRef.database.getAllServices();
|
||||
return allServices.filter(s => this.matchesGlobPattern(s.name, pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple glob pattern matching (supports * and ?)
|
||||
*/
|
||||
private matchesGlobPattern(text: string, pattern: string): boolean {
|
||||
// Convert glob pattern to regex
|
||||
// Escape special regex characters except * and ?
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/\*/g, '.*') // * matches any characters
|
||||
.replace(/\?/g, '.'); // ? matches single character
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||
return regex.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of a schedule's scope
|
||||
*/
|
||||
private getScopeDescription(schedule: IBackupSchedule): string {
|
||||
switch (schedule.scopeType) {
|
||||
case 'all':
|
||||
return 'all services';
|
||||
case 'pattern':
|
||||
return `pattern "${schedule.scopePattern}"`;
|
||||
case 'service':
|
||||
return `service "${schedule.serviceName}"`;
|
||||
default:
|
||||
return 'unknown scope';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of retention policy
|
||||
*/
|
||||
private getRetentionDescription(retention: IRetentionPolicy): string {
|
||||
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts';
|
||||
import type {
|
||||
IApiResponse,
|
||||
ICreateRegistryTokenRequest,
|
||||
IRegistryTokenView,
|
||||
TPlatformServiceType,
|
||||
IContainerStats,
|
||||
IBackupScheduleCreate,
|
||||
IBackupScheduleUpdate,
|
||||
} from '../types.ts';
|
||||
|
||||
export class OneboxHttpServer {
|
||||
private oneboxRef: Onebox;
|
||||
@@ -343,6 +351,26 @@ export class OneboxHttpServer {
|
||||
return await this.handleSetBackupPasswordRequest(req);
|
||||
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
||||
return await this.handleCheckBackupPasswordRequest();
|
||||
// Backup Schedule endpoints
|
||||
} else if (path === '/api/backup-schedules' && method === 'GET') {
|
||||
return await this.handleListBackupSchedulesRequest();
|
||||
} else if (path === '/api/backup-schedules' && method === 'POST') {
|
||||
return await this.handleCreateBackupScheduleRequest(req);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleGetBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleUpdateBackupScheduleRequest(scheduleId, req);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') {
|
||||
const scheduleId = Number(path.split('/').pop());
|
||||
return await this.handleDeleteBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') {
|
||||
const scheduleId = Number(path.split('/')[3]);
|
||||
return await this.handleTriggerBackupScheduleRequest(scheduleId);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') {
|
||||
const serviceName = path.split('/')[3];
|
||||
return await this.handleListServiceBackupSchedulesRequest(serviceName);
|
||||
} else {
|
||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||
}
|
||||
@@ -2311,6 +2339,244 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Backup Schedule Endpoints ============
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
private async handleListBackupSchedulesRequest(): Promise<Response> {
|
||||
try {
|
||||
const schedules = this.oneboxRef.backupScheduler.getAllSchedules();
|
||||
return this.jsonResponse({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new backup schedule
|
||||
*/
|
||||
private async handleCreateBackupScheduleRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json() as IBackupScheduleCreate;
|
||||
|
||||
// Validate scope type
|
||||
if (!body.scopeType) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Scope type is required (all, pattern, or service)',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!['all', 'pattern', 'service'].includes(body.scopeType)) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Invalid scope type. Must be: all, pattern, or service',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate scope-specific requirements
|
||||
if (body.scopeType === 'service' && !body.serviceName) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Service name is required for service-specific schedules',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (body.scopeType === 'pattern' && !body.scopePattern) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Scope pattern is required for pattern-based schedules',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!body.cronExpression) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Cron expression is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (!body.retention) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy is required',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate retention policy
|
||||
const { hourly, daily, weekly, monthly } = body.retention;
|
||||
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention values must be non-negative',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const schedule = await this.oneboxRef.backupScheduler.createSchedule(body);
|
||||
|
||||
// Build descriptive message based on scope type
|
||||
let scopeDesc: string;
|
||||
switch (body.scopeType) {
|
||||
case 'all':
|
||||
scopeDesc = 'all services';
|
||||
break;
|
||||
case 'pattern':
|
||||
scopeDesc = `pattern '${body.scopePattern}'`;
|
||||
break;
|
||||
case 'service':
|
||||
scopeDesc = `service '${body.serviceName}'`;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `Backup schedule created for ${scopeDesc}`,
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to create backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific backup schedule
|
||||
*/
|
||||
private async handleGetBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404);
|
||||
}
|
||||
|
||||
return this.jsonResponse({ success: true, data: schedule });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to get backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json() as IBackupScheduleUpdate;
|
||||
|
||||
// Validate retention policy if provided
|
||||
if (body.retention) {
|
||||
const { hourly, daily, weekly, monthly } = body.retention;
|
||||
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||
}, 400);
|
||||
}
|
||||
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Retention values must be non-negative',
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup schedule updated',
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to update backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup schedule deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to delete backup schedule',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate backup for a schedule
|
||||
*/
|
||||
private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.backupScheduler.triggerBackup(scheduleId);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'Backup triggered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to trigger backup',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List backup schedules for a specific service
|
||||
*/
|
||||
private async handleListServiceBackupSchedulesRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const service = this.oneboxRef.services.getService(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||
}
|
||||
|
||||
const schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName);
|
||||
return this.jsonResponse({ success: true, data: schedules });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create JSON response
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RegistryManager } from './registry.ts';
|
||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||
import { BackupManager } from './backup-manager.ts';
|
||||
import { BackupScheduler } from './backup-scheduler.ts';
|
||||
|
||||
export class Onebox {
|
||||
public database: OneboxDatabase;
|
||||
@@ -38,6 +39,7 @@ export class Onebox {
|
||||
public platformServices: PlatformServicesManager;
|
||||
public caddyLogReceiver: CaddyLogReceiver;
|
||||
public backupManager: BackupManager;
|
||||
public backupScheduler: BackupScheduler;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
@@ -72,6 +74,9 @@ export class Onebox {
|
||||
|
||||
// Initialize Backup manager
|
||||
this.backupManager = new BackupManager(this);
|
||||
|
||||
// Initialize Backup scheduler
|
||||
this.backupScheduler = new BackupScheduler(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +171,14 @@ export class Onebox {
|
||||
// Start auto-update monitoring for registry services
|
||||
this.services.startAutoUpdateMonitoring();
|
||||
|
||||
// Initialize Backup Scheduler (non-critical)
|
||||
try {
|
||||
await this.backupScheduler.init();
|
||||
} catch (error) {
|
||||
logger.warn('Backup scheduler initialization failed - scheduled backups will be disabled');
|
||||
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logger.success('Onebox initialized successfully');
|
||||
} catch (error) {
|
||||
@@ -337,6 +350,9 @@ export class Onebox {
|
||||
try {
|
||||
logger.info('Shutting down Onebox...');
|
||||
|
||||
// Stop backup scheduler
|
||||
await this.backupScheduler.stop();
|
||||
|
||||
// Stop daemon if running
|
||||
await this.daemon.stop();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user