feat(backup): add containerarchive-backed backup storage, restore, download, and pruning support
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,15 @@ export class BackupScheduler {
|
||||
await this.registerTask(schedule);
|
||||
}
|
||||
|
||||
// Add periodic archive prune task (runs daily at 3 AM)
|
||||
const pruneTask = new plugins.taskbuffer.Task({
|
||||
name: 'backup-archive-prune',
|
||||
taskFunction: async () => {
|
||||
await this.pruneArchive();
|
||||
},
|
||||
});
|
||||
this.taskManager.addAndScheduleTask(pruneTask, '0 3 * * *');
|
||||
|
||||
// Start the task manager (activates cron scheduling)
|
||||
await this.taskManager.start();
|
||||
|
||||
@@ -436,9 +445,11 @@ export class BackupScheduler {
|
||||
if (!toKeep.has(backup.id!)) {
|
||||
try {
|
||||
await this.oneboxRef.backupManager.deleteBackup(backup.id!);
|
||||
logger.info(`Deleted backup ${backup.filename} (retention policy)`);
|
||||
const backupRef = backup.snapshotId || backup.filename;
|
||||
logger.info(`Deleted backup ${backupRef} (retention policy)`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`);
|
||||
const backupRef = backup.snapshotId || backup.filename;
|
||||
logger.warn(`Failed to delete old backup ${backupRef}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,4 +658,48 @@ export class BackupScheduler {
|
||||
private getRetentionDescription(retention: IRetentionPolicy): string {
|
||||
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune the containerarchive repository to reclaim storage.
|
||||
* Uses the most generous retention policy across all schedules.
|
||||
*/
|
||||
private async pruneArchive(): Promise<void> {
|
||||
const archive = this.oneboxRef.backupManager.archive;
|
||||
if (!archive) return;
|
||||
|
||||
try {
|
||||
// Compute the most generous retention across all schedules
|
||||
const schedules = this.oneboxRef.database.getAllBackupSchedules();
|
||||
|
||||
// Default minimums if no schedules exist
|
||||
let maxDays = 7;
|
||||
let maxWeeks = 4;
|
||||
let maxMonths = 12;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.retention.daily > maxDays) maxDays = schedule.retention.daily;
|
||||
if (schedule.retention.weekly > maxWeeks) maxWeeks = schedule.retention.weekly;
|
||||
if (schedule.retention.monthly > maxMonths) maxMonths = schedule.retention.monthly;
|
||||
}
|
||||
|
||||
const result = await archive.prune(
|
||||
{
|
||||
keepDays: maxDays,
|
||||
keepWeeks: maxWeeks,
|
||||
keepMonths: maxMonths,
|
||||
},
|
||||
false, // not dry run
|
||||
);
|
||||
|
||||
if (result.removedSnapshots > 0 || result.freedBytes > 0) {
|
||||
const freedMB = Math.round(result.freedBytes / (1024 * 1024) * 10) / 10;
|
||||
logger.info(
|
||||
`Archive prune: removed ${result.removedSnapshots} snapshot(s), ` +
|
||||
`${result.removedPacks} pack(s), freed ${freedMB} MB`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Archive prune failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2161,27 +2161,47 @@ export class OneboxHttpServer {
|
||||
*/
|
||||
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
|
||||
try {
|
||||
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
if (!filePath) {
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
let downloadPath: string | null = null;
|
||||
let tempExport = false;
|
||||
|
||||
if (backup.snapshotId) {
|
||||
// ContainerArchive backup: export as encrypted tar
|
||||
downloadPath = await this.oneboxRef.backupManager.getBackupExportPath(backupId);
|
||||
tempExport = true;
|
||||
} else {
|
||||
// Legacy file-based backup
|
||||
downloadPath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
}
|
||||
|
||||
if (!downloadPath) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup file not available' }, 404);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await Deno.stat(filePath);
|
||||
await Deno.stat(downloadPath);
|
||||
} catch {
|
||||
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
|
||||
}
|
||||
|
||||
// Read file and return as download
|
||||
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||
const file = await Deno.readFile(filePath);
|
||||
const file = await Deno.readFile(downloadPath);
|
||||
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
|
||||
|
||||
// Clean up temp export file
|
||||
if (tempExport) {
|
||||
try { await Deno.remove(downloadPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return new Response(file, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': String(file.length),
|
||||
},
|
||||
});
|
||||
@@ -2241,12 +2261,6 @@ export class OneboxHttpServer {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Get backup file path
|
||||
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||
if (!filePath) {
|
||||
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||
}
|
||||
|
||||
// Validate mode-specific requirements
|
||||
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
|
||||
return this.jsonResponse({
|
||||
@@ -2255,7 +2269,7 @@ export class OneboxHttpServer {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||
const result = await this.oneboxRef.backupManager.restoreBackup(backupId, {
|
||||
mode,
|
||||
newServiceName,
|
||||
overwriteExisting: overwriteExisting === true,
|
||||
|
||||
@@ -192,6 +192,14 @@ export class Onebox {
|
||||
// Start auto-update monitoring for registry services
|
||||
this.services.startAutoUpdateMonitoring();
|
||||
|
||||
// Initialize BackupManager (containerarchive repository, non-critical)
|
||||
try {
|
||||
await this.backupManager.init();
|
||||
} catch (error) {
|
||||
logger.warn('BackupManager initialization failed - backups will be limited');
|
||||
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Initialize Backup Scheduler (non-critical)
|
||||
try {
|
||||
await this.backupScheduler.init();
|
||||
@@ -430,6 +438,9 @@ export class Onebox {
|
||||
// Stop Caddy log receiver
|
||||
await this.caddyLogReceiver.stop();
|
||||
|
||||
// Close backup archive
|
||||
await this.backupManager.close();
|
||||
|
||||
// Close database
|
||||
this.database.close();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Onebox Registry Manager
|
||||
*
|
||||
* Manages the local Docker registry using:
|
||||
* - @push.rocks/smarts3 (S3-compatible server with filesystem storage)
|
||||
* - @push.rocks/smartstorage (S3-compatible server with filesystem storage)
|
||||
* - @push.rocks/smartregistry (OCI-compliant Docker registry)
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@ export class RegistryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry (start smarts3 and smartregistry)
|
||||
* Initialize the registry (start smartstorage and smartregistry)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
@@ -39,10 +39,10 @@ export class RegistryManager {
|
||||
const dataDir = this.options.dataDir || './.nogit/registry-data';
|
||||
const port = this.options.port || 4000;
|
||||
|
||||
logger.info(`Starting smarts3 server on port ${port}...`);
|
||||
logger.info(`Starting smartstorage server on port ${port}...`);
|
||||
|
||||
// 1. Start smarts3 server (S3-compatible storage with filesystem backend)
|
||||
this.s3Server = await plugins.smarts3.Smarts3.createAndStart({
|
||||
// 1. Start smartstorage server (S3-compatible storage with filesystem backend)
|
||||
this.s3Server = await plugins.smartstorage.SmartStorage.createAndStart({
|
||||
server: {
|
||||
port: port,
|
||||
address: '0.0.0.0',
|
||||
@@ -53,16 +53,16 @@ export class RegistryManager {
|
||||
},
|
||||
});
|
||||
|
||||
logger.success(`smarts3 server started on port ${port}`);
|
||||
logger.success(`smartstorage server started on port ${port}`);
|
||||
|
||||
// 2. Configure smartregistry to use smarts3
|
||||
// 2. Configure smartregistry to use smartstorage
|
||||
logger.info('Initializing smartregistry...');
|
||||
|
||||
this.registry = new plugins.smartregistry.SmartRegistry({
|
||||
storage: {
|
||||
endpoint: 'localhost',
|
||||
port: port,
|
||||
accessKey: 'onebox', // smarts3 doesn't validate credentials
|
||||
accessKey: 'onebox', // smartstorage doesn't validate credentials
|
||||
accessSecret: 'onebox',
|
||||
useSsl: false,
|
||||
region: 'us-east-1',
|
||||
@@ -314,15 +314,15 @@ export class RegistryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the registry and smarts3 server
|
||||
* Stop the registry and smartstorage server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.s3Server) {
|
||||
try {
|
||||
await this.s3Server.stop();
|
||||
logger.info('smarts3 server stopped');
|
||||
logger.info('smartstorage server stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping smarts3: ${getErrorMessage(error)}`);
|
||||
logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user