feat(backup): Add backup scheduling system with GFS retention, API and UI integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user