feat(backup): add containerarchive-backed backup storage, restore, download, and pruning support

This commit is contained in:
2026-03-24 19:54:56 +00:00
parent 22a7e76645
commit 0799efadae
18 changed files with 816 additions and 447 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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,

View File

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

View File

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