Compare commits

..

2 Commits

Author SHA1 Message Date
c210507951 v1.24.0
All checks were successful
Release / build-and-release (push) Successful in 3m6s
2026-03-24 19:54:56 +00:00
0799efadae feat(backup): add containerarchive-backed backup storage, restore, download, and pruning support 2026-03-24 19:54:56 +00:00
19 changed files with 818 additions and 449 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## 2026-03-24 - 1.24.0 - feat(backup)
add containerarchive-backed backup storage, restore, download, and pruning support
- add database support for archive snapshot IDs and stored size tracking for backups
- initialize and close the backup archive during onebox lifecycle startup and shutdown
- allow backup download and restore flows to work with archive snapshots as well as legacy file-based backups
- schedule daily archive pruning based on the most generous configured retention policy
- replace smarts3 with smartstorage for registry-backed S3-compatible storage
## 2026-03-21 - 1.23.0 - feat(appstore)
add remote app store templates with service upgrades and Redis/MariaDB platform support

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.23.0",
"version": "1.24.0",
"exports": "./mod.ts",
"tasks": {
"test": "deno test --allow-all test/",
@@ -19,14 +19,15 @@
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0",
"@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.3.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2"
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2",
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3"
},
"compilerOptions": {
"lib": [

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.23.0",
"version": "1.24.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",

View File

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

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

View File

@@ -607,6 +607,10 @@ export class OneboxDatabase {
return this.backupRepo.getBySchedule(scheduleId);
}
getBackupBySnapshotId(snapshotId: string): IBackup | null {
return this.backupRepo.getBySnapshotId(snapshotId);
}
// ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {

View File

@@ -0,0 +1,13 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration014ContainerArchive extends BaseMigration {
readonly version = 14;
readonly description = 'Add containerarchive snapshot tracking to backups';
up(query: TQueryFunction): void {
query('ALTER TABLE backups ADD COLUMN snapshot_id TEXT');
query('ALTER TABLE backups ADD COLUMN stored_size_bytes INTEGER DEFAULT 0');
query('CREATE INDEX IF NOT EXISTS idx_backups_snapshot ON backups(snapshot_id)');
}
}

View File

@@ -20,6 +20,7 @@ import { Migration010BackupSchedules } from './migration-010-backup-schedules.ts
import { Migration011ScopeColumns } from './migration-011-scope-columns.ts';
import { Migration012GfsRetention } from './migration-012-gfs-retention.ts';
import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts';
import { Migration014ContainerArchive } from './migration-014-containerarchive.ts';
import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner {
@@ -44,6 +45,7 @@ export class MigrationRunner {
new Migration011ScopeColumns(),
new Migration012GfsRetention(),
new Migration013AppTemplateVersion(),
new Migration014ContainerArchive(),
].sort((a, b) => a.version - b.version);
}

View File

@@ -20,8 +20,9 @@ export class BackupRepository extends BaseRepository {
this.query(
`INSERT INTO backups (
service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
includes_image, platform_resources, checksum, schedule_id,
snapshot_id, stored_size_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
backup.serviceId,
backup.serviceName,
@@ -32,6 +33,8 @@ export class BackupRepository extends BaseRepository {
JSON.stringify(backup.platformResources),
backup.checksum,
backup.scheduleId ?? null,
backup.snapshotId ?? null,
backup.storedSizeBytes ?? 0,
]
);
@@ -78,6 +81,14 @@ export class BackupRepository extends BaseRepository {
return rows.map((row) => this.rowToBackup(row));
}
getBySnapshotId(snapshotId: string): IBackup | null {
const rows = this.query(
'SELECT * FROM backups WHERE snapshot_id = ?',
[snapshotId]
);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources;
@@ -94,7 +105,9 @@ export class BackupRepository extends BaseRepository {
serviceId: Number(row.service_id),
serviceName: String(row.service_name),
filename: String(row.filename),
snapshotId: row.snapshot_id ? String(row.snapshot_id) : undefined,
sizeBytes: Number(row.size_bytes),
storedSizeBytes: row.stored_size_bytes ? Number(row.stored_size_bytes) : undefined,
createdAt: Number(row.created_at),
includesImage: Boolean(row.includes_image),
platformResources,

View File

@@ -53,12 +53,8 @@ export class BackupsHandler {
'restoreBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backupPath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!backupPath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
backupPath,
dataArg.backupId,
dataArg.options,
);
return {
@@ -84,14 +80,11 @@ export class BackupsHandler {
if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found');
}
const filePath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!filePath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
// Return a download URL that the client can fetch directly
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
return {
downloadUrl: `/api/backups/${dataArg.backupId}/download`,
filename: backup.filename,
filename,
};
},
),

View File

@@ -34,8 +34,8 @@ import * as smartregistry from '@push.rocks/smartregistry';
export { smartregistry };
// S3-compatible storage server
import * as smarts3 from '@push.rocks/smarts3';
export { smarts3 };
import * as smartstorage from '@push.rocks/smartstorage';
export { smartstorage };
// Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer';
@@ -67,3 +67,12 @@ export { typedrequest, typedserver };
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
export { smartguard, smartjwt };
// Backup archive (content-addressed dedup storage)
import { ContainerArchive } from '@serve.zone/containerarchive';
export { ContainerArchive };
// Node.js compat for streaming
import * as nodeFs from 'node:fs';
import * as nodeStream from 'node:stream';
export { nodeFs, nodeStream };

View File

@@ -356,7 +356,9 @@ export interface IBackup {
serviceId: number;
serviceName: string; // Denormalized for display
filename: string;
snapshotId?: string; // ContainerArchive snapshot ID (new backups)
sizeBytes: number;
storedSizeBytes?: number; // Actual stored size after dedup+compression
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up
@@ -399,7 +401,8 @@ export interface IBackupPlatformResource {
export interface IBackupResult {
backup: IBackup;
filePath: string;
filePath?: string; // Legacy file-based backups only
snapshotId?: string; // ContainerArchive snapshot ID
}
export interface IRestoreOptions {

File diff suppressed because one or more lines are too long

View File

@@ -28,7 +28,9 @@ export interface IBackup {
serviceId: number;
serviceName: string;
filename: string;
snapshotId?: string;
sizeBytes: number;
storedSizeBytes?: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[];

View File

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