feat(backup): Add backup system: BackupManager, DB schema, API endpoints and UI support

Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
This commit is contained in:
2025-11-27 13:48:11 +00:00
parent e7ade45097
commit 5cd7e7c252
13 changed files with 2013 additions and 2 deletions

View File

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

1112
ts/classes/backup-manager.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -319,6 +319,30 @@ export class OneboxHttpServer {
return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
// Backup endpoints
} else if (path === '/api/backups' && method === 'GET') {
return await this.handleListBackupsRequest();
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
const serviceName = path.split('/')[3];
return await this.handleListServiceBackupsRequest(serviceName);
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
const serviceName = path.split('/')[3];
return await this.handleCreateBackupRequest(serviceName);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
const backupId = Number(path.split('/').pop());
return await this.handleGetBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
const backupId = Number(path.split('/')[3]);
return await this.handleDownloadBackupRequest(backupId);
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
const backupId = Number(path.split('/').pop());
return await this.handleDeleteBackupRequest(backupId);
} else if (path === '/api/backups/restore' && method === 'POST') {
return await this.handleRestoreBackupRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'POST') {
return await this.handleSetBackupPasswordRequest(req);
} else if (path === '/api/settings/backup-password' && method === 'GET') {
return await this.handleCheckBackupPasswordRequest();
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -2017,6 +2041,276 @@ export class OneboxHttpServer {
}
}
// ============ Backup Endpoints ============
/**
* List all backups
*/
private async handleListBackupsRequest(): Promise<Response> {
try {
const backups = this.oneboxRef.backupManager.listBackups();
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* List backups for a specific service
*/
private async handleListServiceBackupsRequest(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 backups = this.oneboxRef.backupManager.listBackups(serviceName);
return this.jsonResponse({ success: true, data: backups });
} catch (error) {
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to list backups',
}, 500);
}
}
/**
* Create a backup for a service
*/
private async handleCreateBackupRequest(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 result = await this.oneboxRef.backupManager.createBackup(serviceName);
return this.jsonResponse({
success: true,
message: `Backup created for service ${serviceName}`,
data: result.backup,
});
} catch (error) {
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to create backup',
}, 500);
}
}
/**
* Get a specific backup by ID
*/
private async handleGetBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
return this.jsonResponse({ success: true, data: backup });
} catch (error) {
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to get backup',
}, 500);
}
}
/**
* Download a backup file
*/
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
try {
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Check if file exists
try {
await Deno.stat(filePath);
} 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);
return new Response(file, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
'Content-Length': String(file.length),
},
});
} catch (error) {
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to download backup',
}, 500);
}
}
/**
* Delete a backup
*/
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
try {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
await this.oneboxRef.backupManager.deleteBackup(backupId);
return this.jsonResponse({
success: true,
message: 'Backup deleted successfully',
});
} catch (error) {
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to delete backup',
}, 500);
}
}
/**
* Restore a backup
*/
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
if (!backupId) {
return this.jsonResponse({
success: false,
error: 'Backup ID is required',
}, 400);
}
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
return this.jsonResponse({
success: false,
error: 'Valid mode required: restore, import, or clone',
}, 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({
success: false,
error: `New service name required for '${mode}' mode`,
}, 400);
}
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
mode,
newServiceName,
overwriteExisting: overwriteExisting === true,
skipPlatformData: skipPlatformData === true,
});
return this.jsonResponse({
success: true,
message: `Backup restored successfully as service '${result.service.name}'`,
data: {
service: result.service,
platformResourcesRestored: result.platformResourcesRestored,
warnings: result.warnings,
},
});
} catch (error) {
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to restore backup',
}, 500);
}
}
/**
* Set backup encryption password
*/
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { password } = body;
if (!password || typeof password !== 'string') {
return this.jsonResponse({
success: false,
error: 'Password is required',
}, 400);
}
if (password.length < 8) {
return this.jsonResponse({
success: false,
error: 'Password must be at least 8 characters',
}, 400);
}
// Store password in settings
this.oneboxRef.database.setSetting('backup_encryption_password', password);
return this.jsonResponse({
success: true,
message: 'Backup password set successfully',
});
} catch (error) {
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to set backup password',
}, 500);
}
}
/**
* Check if backup password is configured
*/
private async handleCheckBackupPasswordRequest(): Promise<Response> {
try {
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
const isConfigured = password !== null && password.length > 0;
return this.jsonResponse({
success: true,
data: {
isConfigured,
},
});
} catch (error) {
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to check backup password',
}, 500);
}
}
/**
* Helper to create JSON response
*/

View File

@@ -20,6 +20,7 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
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';
export class Onebox {
public database: OneboxDatabase;
@@ -36,6 +37,7 @@ export class Onebox {
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager;
private initialized = false;
@@ -67,6 +69,9 @@ export class Onebox {
// Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999);
// Initialize Backup manager
this.backupManager = new BackupManager(this);
}
/**

View File

@@ -18,6 +18,7 @@ import type {
IDomain,
ICertificate,
ICertRequirement,
IBackup,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -31,6 +32,7 @@ import {
AuthRepository,
MetricsRepository,
PlatformRepository,
BackupRepository,
} from './repositories/index.ts';
export class OneboxDatabase {
@@ -44,6 +46,7 @@ export class OneboxDatabase {
private authRepo!: AuthRepository;
private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository;
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
@@ -76,6 +79,7 @@ export class OneboxDatabase {
this.authRepo = new AuthRepository(queryFn);
this.metricsRepo = new MetricsRepository(queryFn);
this.platformRepo = new PlatformRepository(queryFn);
this.backupRepo = new BackupRepository(queryFn);
} catch (error) {
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw error;
@@ -705,6 +709,37 @@ export class OneboxDatabase {
this.setMigrationVersion(8);
logger.success('Migration 8 completed: Certificates table now stores PEM content');
}
// Migration 9: Backup system tables
const version9 = this.getMigrationVersion();
if (version9 < 9) {
logger.info('Running migration 9: Creating backup system tables...');
// Add include_image_in_backup column to services table
this.query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`);
// Create backups table
this.query(`
CREATE TABLE backups (
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,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
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.setMigrationVersion(9);
logger.success('Migration 9 completed: Backup system tables created');
}
} catch (error) {
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
@@ -1078,4 +1113,30 @@ export class OneboxDatabase {
deletePlatformResourcesByService(serviceId: number): void {
this.platformRepo.deletePlatformResourcesByService(serviceId);
}
// ============ Backups (delegated to repository) ============
createBackup(backup: Omit<IBackup, 'id'>): IBackup {
return this.backupRepo.create(backup);
}
getBackupById(id: number): IBackup | null {
return this.backupRepo.getById(id);
}
getBackupsByService(serviceId: number): IBackup[] {
return this.backupRepo.getByService(serviceId);
}
getAllBackups(): IBackup[] {
return this.backupRepo.getAll();
}
deleteBackup(id: number): void {
this.backupRepo.delete(id);
}
deleteBackupsByService(serviceId: number): void {
this.backupRepo.deleteByService(serviceId);
}
}

View File

@@ -0,0 +1,86 @@
/**
* Backup Repository
* Handles CRUD operations for backups table
*/
import { BaseRepository } from '../base.repository.ts';
import type { IBackup, TPlatformServiceType } from '../../types.ts';
export class BackupRepository extends BaseRepository {
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 (?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
backup.filename,
backup.sizeBytes,
backup.createdAt,
backup.includesImage ? 1 : 0,
JSON.stringify(backup.platformResources),
backup.checksum,
]
);
// Get the created backup by looking for the most recent one with matching filename
const rows = this.query(
'SELECT * FROM backups WHERE filename = ? ORDER BY id DESC LIMIT 1',
[backup.filename]
);
return this.rowToBackup(rows[0]);
}
getById(id: number): IBackup | null {
const rows = this.query('SELECT * FROM backups WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
getByService(serviceId: number): IBackup[] {
const rows = this.query(
'SELECT * FROM backups WHERE service_id = ? ORDER BY created_at DESC',
[serviceId]
);
return rows.map((row) => this.rowToBackup(row));
}
getAll(): IBackup[] {
const rows = this.query('SELECT * FROM backups ORDER BY created_at DESC');
return rows.map((row) => this.rowToBackup(row));
}
delete(id: number): void {
this.query('DELETE FROM backups WHERE id = ?', [id]);
}
deleteByService(serviceId: number): void {
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
}
private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources;
if (platformResourcesRaw) {
try {
platformResources = JSON.parse(String(platformResourcesRaw));
} catch {
platformResources = [];
}
}
return {
id: Number(row.id),
serviceId: Number(row.service_id),
serviceName: String(row.service_name),
filename: String(row.filename),
sizeBytes: Number(row.size_bytes),
createdAt: Number(row.created_at),
includesImage: Boolean(row.includes_image),
platformResources,
checksum: String(row.checksum),
};
}
}

View File

@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
export { AuthRepository } from './auth.repository.ts';
export { MetricsRepository } from './metrics.repository.ts';
export { PlatformRepository } from './platform.repository.ts';
export { BackupRepository } from './backup.repository.ts';

View File

@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
fields.push('platform_requirements = ?');
values.push(JSON.stringify(updates.platformRequirements));
}
if (updates.includeImageInBackup !== undefined) {
fields.push('include_image_in_backup = ?');
values.push(updates.includeImageInBackup ? 1 : 0);
}
fields.push('updated_at = ?');
values.push(Date.now());
@@ -172,6 +176,9 @@ export class ServiceRepository extends BaseRepository {
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
platformRequirements,
includeImageInBackup: row.include_image_in_backup !== undefined
? Boolean(row.include_image_in_backup)
: true, // Default to true
};
}
}

View File

@@ -23,6 +23,8 @@ export interface IService {
imageDigest?: string;
// Platform service requirements
platformRequirements?: IPlatformRequirements;
// Backup settings
includeImageInBackup?: boolean;
}
// Registry types
@@ -317,3 +319,68 @@ export interface ICliArgs {
_: string[];
[key: string]: unknown;
}
// Backup types
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string; // Denormalized for display
filename: string;
sizeBytes: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up
checksum: string;
}
export interface IBackupManifest {
version: string;
createdAt: number;
oneboxVersion: string;
serviceName: string;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
}
export interface IBackupServiceConfig {
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
platformRequirements?: IPlatformRequirements;
includeImageInBackup?: boolean;
}
export interface IBackupPlatformResource {
resourceType: TPlatformResourceType;
resourceName: string;
platformServiceType: TPlatformServiceType;
credentials: Record<string, string>; // Decrypted for backup, re-encrypted on restore
}
export interface IBackupResult {
backup: IBackup;
filePath: string;
}
export interface IRestoreOptions {
mode: TBackupRestoreMode;
newServiceName?: string; // Required for 'import' and 'clone' modes
skipPlatformData?: boolean; // Restore config only, skip DB/bucket data
overwriteExisting?: boolean; // For 'restore' mode
}
export interface IRestoreResult {
service: IService;
platformResourcesRestored: number;
warnings: string[];
}