feat(backup): Add backup scheduling system with GFS retention, API and UI integration

This commit is contained in:
2025-11-27 21:42:07 +00:00
parent c5d239ab28
commit 6ba7e655e3
17 changed files with 2319 additions and 12 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.7.0',
version: '1.8.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

View File

@@ -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);

View 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}`;
}
}

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -19,6 +19,8 @@ import type {
ICertificate,
ICertRequirement,
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -740,6 +742,183 @@ export class OneboxDatabase {
this.setMigrationVersion(9);
logger.success('Migration 9 completed: Backup system tables created');
}
// Migration 10: Backup schedules table and extend backups table
const version10 = this.getMigrationVersion();
if (version10 < 10) {
logger.info('Running migration 10: Creating backup schedules table...');
// Create backup_schedules table
this.query(`
CREATE TABLE backup_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
// Extend backups table with retention_tier and schedule_id columns
this.query('ALTER TABLE backups ADD COLUMN retention_tier TEXT');
this.query('ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL');
this.setMigrationVersion(10);
logger.success('Migration 10 completed: Backup schedules table created');
}
// Migration 11: Add scope columns for global/pattern backup schedules
const version11 = this.getMigrationVersion();
if (version11 < 11) {
logger.info('Running migration 11: Adding scope columns to backup_schedules...');
// Recreate backup_schedules table with nullable service_id/service_name and new scope columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_tier TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Copy existing schedules (all are service-specific)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
)
SELECT
id, 'service', NULL, service_id, service_name, cron_expression,
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
this.setMigrationVersion(11);
logger.success('Migration 11 completed: Scope columns added to backup_schedules');
}
// Migration 12: GFS retention policy - replace retention_tier with per-tier retention counts
const version12 = this.getMigrationVersion();
if (version12 < 12) {
logger.info('Running migration 12: Updating backup system for GFS retention policy...');
// Recreate backup_schedules table with new retention columns
this.query(`
CREATE TABLE backup_schedules_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL DEFAULT 'service',
scope_pattern TEXT,
service_id INTEGER,
service_name TEXT,
cron_expression TEXT NOT NULL,
retention_hourly INTEGER NOT NULL DEFAULT 0,
retention_daily INTEGER NOT NULL DEFAULT 7,
retention_weekly INTEGER NOT NULL DEFAULT 4,
retention_monthly INTEGER NOT NULL DEFAULT 12,
enabled INTEGER NOT NULL DEFAULT 1,
last_run_at REAL,
next_run_at REAL,
last_status TEXT,
last_error TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Migrate existing data - convert old retention_tier to new format
// daily -> D:7, weekly -> W:4, monthly -> M:12, yearly -> M:12 (yearly becomes long monthly retention)
this.query(`
INSERT INTO backup_schedules_new (
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
)
SELECT
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
0, -- retention_hourly
CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END,
CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12
WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
FROM backup_schedules
`);
this.query('DROP TABLE backup_schedules');
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
// Recreate backups table without retention_tier column
this.query(`
CREATE TABLE backups_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
service_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at REAL NOT NULL,
includes_image INTEGER NOT NULL,
platform_resources TEXT NOT NULL DEFAULT '[]',
checksum TEXT NOT NULL,
schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
this.query(`
INSERT INTO backups_new (
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
)
SELECT
id, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
FROM backups
`);
this.query('DROP TABLE backups');
this.query('ALTER TABLE backups_new RENAME TO backups');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
this.query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)');
this.setMigrationVersion(12);
logger.success('Migration 12 completed: GFS retention policy schema updated');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
@@ -1139,4 +1318,42 @@ export class OneboxDatabase {
deleteBackupsByService(serviceId: number): void {
this.backupRepo.deleteByService(serviceId);
}
getBackupsBySchedule(scheduleId: number): IBackup[] {
return this.backupRepo.getBySchedule(scheduleId);
}
// ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
return this.backupRepo.createSchedule(schedule);
}
getBackupScheduleById(id: number): IBackupSchedule | null {
return this.backupRepo.getScheduleById(id);
}
getBackupSchedulesByService(serviceId: number): IBackupSchedule[] {
return this.backupRepo.getSchedulesByService(serviceId);
}
getEnabledBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getEnabledSchedules();
}
getAllBackupSchedules(): IBackupSchedule[] {
return this.backupRepo.getAllSchedules();
}
updateBackupSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
this.backupRepo.updateSchedule(id, updates);
}
deleteBackupSchedule(id: number): void {
this.backupRepo.deleteSchedule(id);
}
deleteBackupSchedulesByService(serviceId: number): void {
this.backupRepo.deleteSchedulesByService(serviceId);
}
}

View File

@@ -1,18 +1,27 @@
/**
* Backup Repository
* Handles CRUD operations for backups table
* Handles CRUD operations for backups and backup_schedules tables
*/
import { BaseRepository } from '../base.repository.ts';
import type { IBackup, TPlatformServiceType } from '../../types.ts';
import type {
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
TPlatformServiceType,
TBackupScheduleScope,
IRetentionPolicy,
} from '../../types.ts';
export class BackupRepository extends BaseRepository {
// ============ Backup CRUD ============
create(backup: Omit<IBackup, 'id'>): IBackup {
this.query(
`INSERT INTO backups (
service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
includes_image, platform_resources, checksum, schedule_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
@@ -22,6 +31,7 @@ export class BackupRepository extends BaseRepository {
backup.includesImage ? 1 : 0,
JSON.stringify(backup.platformResources),
backup.checksum,
backup.scheduleId ?? null,
]
);
@@ -60,6 +70,14 @@ export class BackupRepository extends BaseRepository {
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
}
getBySchedule(scheduleId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE schedule_id = ? ORDER BY created_at DESC',
[scheduleId]
);
return rows.map((row) => this.rowToBackup(row));
}
private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources;
@@ -81,6 +99,151 @@ export class BackupRepository extends BaseRepository {
includesImage: Boolean(row.includes_image),
platformResources,
checksum: String(row.checksum),
scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined,
};
}
// ============ Backup Schedule CRUD ============
createSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
const now = Date.now();
this.query(
`INSERT INTO backup_schedules (
scope_type, scope_pattern, service_id, service_name, cron_expression,
retention_hourly, retention_daily, retention_weekly, retention_monthly,
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
schedule.scopeType,
schedule.scopePattern ?? null,
schedule.serviceId ?? null,
schedule.serviceName ?? null,
schedule.cronExpression,
schedule.retention.hourly,
schedule.retention.daily,
schedule.retention.weekly,
schedule.retention.monthly,
schedule.enabled ? 1 : 0,
schedule.lastRunAt,
schedule.nextRunAt,
schedule.lastStatus,
schedule.lastError,
now,
now,
]
);
// Get the created schedule by looking for the most recent one with matching scope
const rows = this.query(
'SELECT * FROM backup_schedules WHERE scope_type = ? AND cron_expression = ? ORDER BY id DESC LIMIT 1',
[schedule.scopeType, schedule.cronExpression]
);
return this.rowToSchedule(rows[0]);
}
getScheduleById(id: number): IBackupSchedule | null {
const rows = this.query('SELECT * FROM backup_schedules WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToSchedule(rows[0]) : null;
}
getSchedulesByService(serviceId: number): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToSchedule(row));
}
getEnabledSchedules(): IBackupSchedule[] {
const rows = this.query(
'SELECT * FROM backup_schedules WHERE enabled = 1 ORDER BY next_run_at ASC'
);
return rows.map((row) => this.rowToSchedule(row));
}
getAllSchedules(): IBackupSchedule[] {
const rows = this.query('SELECT * FROM backup_schedules ORDER BY created_at DESC');
return rows.map((row) => this.rowToSchedule(row));
}
updateSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
const setClauses: string[] = [];
const params: (string | number | null)[] = [];
if (updates.cronExpression !== undefined) {
setClauses.push('cron_expression = ?');
params.push(updates.cronExpression);
}
if (updates.retention !== undefined) {
setClauses.push('retention_hourly = ?');
params.push(updates.retention.hourly);
setClauses.push('retention_daily = ?');
params.push(updates.retention.daily);
setClauses.push('retention_weekly = ?');
params.push(updates.retention.weekly);
setClauses.push('retention_monthly = ?');
params.push(updates.retention.monthly);
}
if (updates.enabled !== undefined) {
setClauses.push('enabled = ?');
params.push(updates.enabled ? 1 : 0);
}
if (updates.lastRunAt !== undefined) {
setClauses.push('last_run_at = ?');
params.push(updates.lastRunAt);
}
if (updates.nextRunAt !== undefined) {
setClauses.push('next_run_at = ?');
params.push(updates.nextRunAt);
}
if (updates.lastStatus !== undefined) {
setClauses.push('last_status = ?');
params.push(updates.lastStatus);
}
if (updates.lastError !== undefined) {
setClauses.push('last_error = ?');
params.push(updates.lastError);
}
if (setClauses.length === 0) return;
setClauses.push('updated_at = ?');
params.push(Date.now());
params.push(id);
this.query(`UPDATE backup_schedules SET ${setClauses.join(', ')} WHERE id = ?`, params);
}
deleteSchedule(id: number): void {
this.query('DELETE FROM backup_schedules WHERE id = ?', [id]);
}
deleteSchedulesByService(serviceId: number): void {
this.query('DELETE FROM backup_schedules WHERE service_id = ?', [serviceId]);
}
private rowToSchedule(row: any): IBackupSchedule {
return {
id: Number(row.id),
scopeType: (String(row.scope_type) || 'service') as TBackupScheduleScope,
scopePattern: row.scope_pattern ? String(row.scope_pattern) : undefined,
serviceId: row.service_id ? Number(row.service_id) : undefined,
serviceName: row.service_name ? String(row.service_name) : undefined,
cronExpression: String(row.cron_expression),
retention: {
hourly: Number(row.retention_hourly ?? 0),
daily: Number(row.retention_daily ?? 7),
weekly: Number(row.retention_weekly ?? 4),
monthly: Number(row.retention_monthly ?? 12),
} as IRetentionPolicy,
enabled: Boolean(row.enabled),
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
nextRunAt: row.next_run_at ? Number(row.next_run_at) : null,
lastStatus: row.last_status ? (String(row.last_status) as 'success' | 'failed') : null,
lastError: row.last_error ? String(row.last_error) : null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
}

View File

@@ -41,6 +41,10 @@ export { smartregistry };
import * as smarts3 from '@push.rocks/smarts3';
export { smarts3 };
// Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer';
export { taskbuffer };
// Crypto utilities (for password hashing, encryption)
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
export { bcrypt };

View File

@@ -323,6 +323,24 @@ export interface ICliArgs {
// Backup types
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
export interface IRetentionPolicy {
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
daily: number; // Keep 1 backup per day for last N days
weekly: number; // Keep 1 backup per week for last N weeks
monthly: number; // Keep 1 backup per month for last N months
}
// Default retention presets
export const RETENTION_PRESETS = {
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
} as const;
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
export interface IBackup {
id?: number;
serviceId: number;
@@ -333,6 +351,8 @@ export interface IBackup {
includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up
checksum: string;
// Scheduled backup fields
scheduleId?: number; // Links backup to its schedule for retention
}
export interface IBackupManifest {
@@ -384,3 +404,43 @@ export interface IRestoreResult {
platformResourcesRestored: number;
warnings: string[];
}
// Backup scheduling types (GFS retention scheme)
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
export interface IBackupSchedule {
id?: number;
scopeType: TBackupScheduleScope;
scopePattern?: string; // Glob pattern for 'pattern' scope type
serviceId?: number; // Only for 'service' scope type
serviceName?: string; // Only for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy; // Per-tier retention counts
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
lastStatus: 'success' | 'failed' | null;
lastError: string | null;
createdAt: number;
updatedAt: number;
}
export interface IBackupScheduleCreate {
scopeType: TBackupScheduleScope;
scopePattern?: string; // Required for 'pattern' scope type
serviceName?: string; // Required for 'service' scope type
cronExpression: string;
retention: IRetentionPolicy;
enabled?: boolean;
}
export interface IBackupScheduleUpdate {
cronExpression?: string;
retention?: IRetentionPolicy;
enabled?: boolean;
}
// Backup creation options (for scheduled backups)
export interface IBackupCreateOptions {
scheduleId?: number;
}