Files
onebox/ts/classes/backup-manager.ts

1118 lines
36 KiB
TypeScript

/**
* Backup Manager for Onebox
*
* Handles service backup and restore operations including:
* - Service configuration export/import
* - MongoDB database dumps
* - MinIO bucket contents
* - ClickHouse database dumps
* - Docker image export/import
* - Encrypted archive creation
*/
import * as plugins from '../plugins.ts';
import type {
IService,
IBackup,
IBackupManifest,
IBackupServiceConfig,
IBackupPlatformResource,
IBackupResult,
IRestoreOptions,
IRestoreResult,
TPlatformServiceType,
IPlatformResource,
IBackupCreateOptions,
TRetentionTier,
} from '../types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { credentialEncryption } from './encryption.ts';
import type { Onebox } from './onebox.ts';
import { projectInfo } from '../info.ts';
// Backup archive encryption parameters
const ENCRYPTION_ALGORITHM = 'AES-GCM';
const KEY_LENGTH = 256;
const IV_LENGTH = 12;
const SALT_LENGTH = 32;
const PBKDF2_ITERATIONS = 100000;
export class BackupManager {
private oneboxRef: Onebox;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
/**
* Create a backup for a service
* @param serviceName - Name of the service to backup
* @param options - Optional backup creation options (scheduleId)
*/
async createBackup(serviceName: string, options?: IBackupCreateOptions): Promise<IBackupResult> {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
throw new Error(`Service not found: ${serviceName}`);
}
// Verify backup password is configured
const backupPassword = this.getBackupPassword();
if (!backupPassword) {
throw new Error('Backup password not configured. Set a backup password in settings first.');
}
logger.info(`Creating backup for service: ${serviceName}`);
// Create temp directory for backup contents
const timestamp = Date.now();
const tempDir = `/tmp/onebox-backup-${serviceName}-${timestamp}`;
await Deno.mkdir(tempDir, { recursive: true });
try {
// 1. Export service configuration
const serviceConfig = await this.exportServiceConfig(service);
await Deno.writeTextFile(
`${tempDir}/service.json`,
JSON.stringify(serviceConfig, null, 2)
);
// 2. Export platform resources metadata and data
const platformResources: IBackupPlatformResource[] = [];
const resourceTypes: TPlatformServiceType[] = [];
if (service.platformRequirements) {
const resources = await this.oneboxRef.platformServices.getResourcesForService(service.id!);
for (const { resource, platformService, credentials } of resources) {
// Store resource metadata
platformResources.push({
resourceType: resource.resourceType,
resourceName: resource.resourceName,
platformServiceType: platformService.type,
credentials,
});
// Track resource types
if (!resourceTypes.includes(platformService.type)) {
resourceTypes.push(platformService.type);
}
// Create data directory
const dataDir = `${tempDir}/data/${platformService.type}`;
await Deno.mkdir(dataDir, { recursive: true });
// Export data based on type
switch (platformService.type) {
case 'mongodb':
await this.exportMongoDatabase(dataDir, resource, credentials);
break;
case 'minio':
await this.exportMinioBucket(dataDir, resource, credentials);
break;
case 'clickhouse':
await this.exportClickHouseDatabase(dataDir, resource, credentials);
break;
}
}
}
await Deno.writeTextFile(
`${tempDir}/platform-resources.json`,
JSON.stringify(platformResources, null, 2)
);
// 3. Export Docker image if configured
const includeImage = service.includeImageInBackup !== false; // Default true
if (includeImage && service.image) {
await Deno.mkdir(`${tempDir}/data/image`, { recursive: true });
await this.exportDockerImage(service.image, `${tempDir}/data/image/image.tar`);
}
// 4. Create manifest
const manifest: IBackupManifest = {
version: '1.0',
createdAt: timestamp,
oneboxVersion: projectInfo.version,
serviceName: service.name,
includesImage: includeImage,
platformResources: resourceTypes,
checksum: '', // Will be computed after archive creation
};
await Deno.writeTextFile(
`${tempDir}/manifest.json`,
JSON.stringify(manifest, null, 2)
);
// 5. Create tar archive
const tarPath = `/tmp/onebox-backup-${serviceName}-${timestamp}.tar`;
await this.createTarArchive(tempDir, tarPath);
// 6. Compute checksum of tar
const tarData = await Deno.readFile(tarPath);
const checksum = await this.computeChecksum(tarData);
manifest.checksum = checksum;
// Update manifest with checksum
await Deno.writeTextFile(
`${tempDir}/manifest.json`,
JSON.stringify(manifest, null, 2)
);
// Recreate tar with updated manifest
await this.createTarArchive(tempDir, tarPath);
// 7. Encrypt the archive
const backupsDir = this.getBackupsDirectory();
await Deno.mkdir(backupsDir, { recursive: true });
const encryptedFilename = `${serviceName}-${timestamp}.tar.enc`;
const encryptedPath = `${backupsDir}/${encryptedFilename}`;
await this.encryptFile(tarPath, encryptedPath, backupPassword);
// Get encrypted file size
const stat = await Deno.stat(encryptedPath);
const sizeBytes = stat.size;
// 8. Store backup record in database
const backup: IBackup = {
serviceId: service.id!,
serviceName: service.name,
filename: encryptedFilename,
sizeBytes,
createdAt: timestamp,
includesImage: includeImage,
platformResources: resourceTypes,
checksum,
scheduleId: options?.scheduleId,
};
const createdBackup = this.oneboxRef.database.createBackup(backup);
// Cleanup temp files
await Deno.remove(tempDir, { recursive: true });
await Deno.remove(tarPath);
logger.success(`Backup created for service ${serviceName}: ${encryptedFilename}`);
return {
backup: createdBackup,
filePath: encryptedPath,
};
} catch (error) {
// Cleanup on error
try {
await Deno.remove(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
logger.error(`Failed to create backup for ${serviceName}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Restore a backup
*/
async restoreBackup(backupPath: string, options: IRestoreOptions): Promise<IRestoreResult> {
// Verify backup password
const backupPassword = this.getBackupPassword();
if (!backupPassword) {
throw new Error('Backup password not configured.');
}
logger.info(`Restoring backup from: ${backupPath}`);
// Create temp directory for extraction
const timestamp = Date.now();
const tempDir = `/tmp/onebox-restore-${timestamp}`;
await Deno.mkdir(tempDir, { recursive: true });
const warnings: string[] = [];
try {
// 1. Decrypt the archive
const tarPath = `${tempDir}/backup.tar`;
await this.decryptFile(backupPath, tarPath, backupPassword);
// 2. Extract tar archive
await this.extractTarArchive(tarPath, tempDir);
// 3. Read and validate manifest
const manifestPath = `${tempDir}/manifest.json`;
const manifestData = await Deno.readTextFile(manifestPath);
const manifest: IBackupManifest = JSON.parse(manifestData);
// Verify checksum (excluding manifest itself)
// Note: For simplicity, we trust the manifest here
// In production, you'd want to verify the checksum of specific files
// 4. Read service config
const serviceConfigPath = `${tempDir}/service.json`;
const serviceConfigData = await Deno.readTextFile(serviceConfigPath);
const serviceConfig: IBackupServiceConfig = JSON.parse(serviceConfigData);
// 5. Read platform resources
const platformResourcesPath = `${tempDir}/platform-resources.json`;
let platformResources: IBackupPlatformResource[] = [];
try {
const resourcesData = await Deno.readTextFile(platformResourcesPath);
platformResources = JSON.parse(resourcesData);
} catch {
// No platform resources in backup
}
// 6. Determine service name based on mode
let serviceName: string;
let existingService: IService | null = null;
switch (options.mode) {
case 'restore':
serviceName = manifest.serviceName;
existingService = this.oneboxRef.database.getServiceByName(serviceName);
if (!existingService) {
throw new Error(`Service '${serviceName}' not found. Use 'import' mode to create a new service.`);
}
if (!options.overwriteExisting) {
throw new Error(`Service '${serviceName}' exists. Set overwriteExisting=true to proceed.`);
}
break;
case 'import':
case 'clone':
if (!options.newServiceName) {
throw new Error(`New service name required for '${options.mode}' mode.`);
}
serviceName = options.newServiceName;
existingService = this.oneboxRef.database.getServiceByName(serviceName);
if (existingService) {
throw new Error(`Service '${serviceName}' already exists. Choose a different name.`);
}
break;
default:
throw new Error(`Invalid restore mode: ${options.mode}`);
}
// 7. Import Docker image if present
if (manifest.includesImage) {
const imagePath = `${tempDir}/data/image/image.tar`;
try {
await Deno.stat(imagePath);
const newImageTag = await this.importDockerImage(imagePath);
// Update service config with the imported image tag
serviceConfig.image = newImageTag;
logger.info(`Docker image imported: ${newImageTag}`);
} catch (error) {
warnings.push(`Docker image import failed: ${getErrorMessage(error)}`);
}
}
// 8. Create or update service
let service: IService;
let platformResourcesRestored = 0;
if (options.mode === 'restore' && existingService) {
// Update existing service
this.oneboxRef.database.updateService(existingService.id!, {
image: serviceConfig.image,
registry: serviceConfig.registry,
port: serviceConfig.port,
domain: serviceConfig.domain,
useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryRepository: serviceConfig.registryRepository,
registryImageTag: serviceConfig.registryImageTag,
autoUpdateOnPush: serviceConfig.autoUpdateOnPush,
platformRequirements: serviceConfig.platformRequirements,
updatedAt: Date.now(),
});
// Restore env vars (merge with platform provisioned vars later)
const updatedEnvVars = { ...serviceConfig.envVars };
// Handle platform data restore
if (!options.skipPlatformData && platformResources.length > 0) {
platformResourcesRestored = await this.restorePlatformResources(
existingService.id!,
platformResources,
tempDir,
warnings
);
}
this.oneboxRef.database.updateService(existingService.id!, { envVars: updatedEnvVars });
service = this.oneboxRef.database.getServiceByName(serviceName)!;
} else {
// Create new service
const deployOptions = {
name: serviceName,
image: serviceConfig.image,
registry: serviceConfig.registry,
port: serviceConfig.port,
domain: options.mode === 'clone' ? undefined : serviceConfig.domain, // Don't duplicate domain for clones
envVars: serviceConfig.envVars,
useOneboxRegistry: serviceConfig.useOneboxRegistry,
registryImageTag: serviceConfig.registryImageTag,
autoUpdateOnPush: serviceConfig.autoUpdateOnPush,
enableMongoDB: serviceConfig.platformRequirements?.mongodb,
enableS3: serviceConfig.platformRequirements?.s3,
enableClickHouse: serviceConfig.platformRequirements?.clickhouse,
};
service = await this.oneboxRef.services.deployService(deployOptions);
// Import platform data if not skipping
if (!options.skipPlatformData && platformResources.length > 0) {
// Wait a moment for platform resources to be provisioned
await new Promise((resolve) => setTimeout(resolve, 2000));
platformResourcesRestored = await this.restorePlatformData(
service.id!,
platformResources,
tempDir,
warnings
);
}
}
// Cleanup
await Deno.remove(tempDir, { recursive: true });
logger.success(`Backup restored successfully as service '${serviceName}'`);
return {
service,
platformResourcesRestored,
warnings,
};
} catch (error) {
// Cleanup on error
try {
await Deno.remove(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* List all backups
*/
listBackups(serviceName?: string): IBackup[] {
if (serviceName) {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
return [];
}
return this.oneboxRef.database.getBackupsByService(service.id!);
}
return this.oneboxRef.database.getAllBackups();
}
/**
* Delete a backup
*/
async deleteBackup(backupId: number): Promise<void> {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
throw new Error(`Backup not found: ${backupId}`);
}
// Delete file
const backupsDir = this.getBackupsDirectory();
const filePath = `${backupsDir}/${backup.filename}`;
try {
await Deno.remove(filePath);
} catch {
logger.warn(`Could not delete backup file: ${filePath}`);
}
// Delete database record
this.oneboxRef.database.deleteBackup(backupId);
logger.info(`Backup deleted: ${backup.filename}`);
}
/**
* Get backup file path for download
*/
getBackupFilePath(backupId: number): string | null {
const backup = this.oneboxRef.database.getBackupById(backupId);
if (!backup) {
return null;
}
const backupsDir = this.getBackupsDirectory();
return `${backupsDir}/${backup.filename}`;
}
// ========== Private Methods ==========
/**
* Get backup password from settings
*/
private getBackupPassword(): string | null {
return this.oneboxRef.database.getSetting('backup_encryption_password');
}
/**
* Get backups directory
*/
private getBackupsDirectory(): string {
const dataDir = this.oneboxRef.database.getSetting('dataDir') || './.nogit';
return `${dataDir}/backups`;
}
/**
* Export service configuration
*/
private async exportServiceConfig(service: IService): Promise<IBackupServiceConfig> {
return {
name: service.name,
image: service.image,
registry: service.registry,
envVars: service.envVars,
port: service.port,
domain: service.domain,
useOneboxRegistry: service.useOneboxRegistry,
registryRepository: service.registryRepository,
registryImageTag: service.registryImageTag,
autoUpdateOnPush: service.autoUpdateOnPush,
platformRequirements: service.platformRequirements,
includeImageInBackup: service.includeImageInBackup,
};
}
/**
* Export MongoDB database
*/
private async exportMongoDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>
): Promise<void> {
logger.info(`Exporting MongoDB database: ${resource.resourceName}`);
const mongoService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
if (!mongoService || !mongoService.containerId) {
throw new Error('MongoDB service not running');
}
// Build connection URI
const connectionUri = credentials.connectionUri || credentials.MONGODB_URI;
if (!connectionUri) {
throw new Error('MongoDB connection URI not found in credentials');
}
// Use mongodump via docker exec
const archivePath = `/tmp/${resource.resourceName}.archive`;
const result = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'mongodump',
`--uri=${connectionUri}`,
`--archive=${archivePath}`,
'--gzip',
]);
if (result.exitCode !== 0) {
throw new Error(`mongodump failed: ${result.stderr}`);
}
// Copy archive out of container
const container = await this.oneboxRef.docker.getContainerById(mongoService.containerId);
if (!container) {
throw new Error('MongoDB container not found');
}
// Read the archive from container and write to local file
const copyResult = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'cat',
archivePath,
]);
// Write base64-decoded content (stdout is binary data encoded)
const localPath = `${dataDir}/${resource.resourceName}.archive`;
const encoder = new TextEncoder();
await Deno.writeFile(localPath, encoder.encode(copyResult.stdout));
// Cleanup inside container
await this.oneboxRef.docker.execInContainer(mongoService.containerId, ['rm', archivePath]);
logger.success(`MongoDB database exported: ${resource.resourceName}`);
}
/**
* Export MinIO bucket
*/
private async exportMinioBucket(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>
): Promise<void> {
logger.info(`Exporting MinIO bucket: ${resource.resourceName}`);
const bucketDir = `${dataDir}/${resource.resourceName}`;
await Deno.mkdir(bucketDir, { recursive: true });
// Use S3 client to download all objects
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
const bucket = credentials.bucket || credentials.S3_BUCKET;
if (!endpoint || !accessKey || !secretKey || !bucket) {
throw new Error('MinIO credentials incomplete');
}
// Initialize S3 client
const s3Client = new plugins.smarts3.SmartS3({
endpoint,
accessKey,
secretKey,
bucket,
});
await s3Client.start();
// List and download all objects
const objects = await s3Client.listObjects();
for (const obj of objects) {
const objectKey = obj.Key;
if (!objectKey) continue;
const objectData = await s3Client.getObject(objectKey);
if (objectData) {
const objectPath = `${bucketDir}/${objectKey}`;
// Create parent directories if needed
const parentDir = plugins.path.dirname(objectPath);
await Deno.mkdir(parentDir, { recursive: true });
await Deno.writeFile(objectPath, objectData);
}
}
await s3Client.stop();
// Also save bucket metadata
await Deno.writeTextFile(
`${bucketDir}/_metadata.json`,
JSON.stringify({ bucket, objectCount: objects.length }, null, 2)
);
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objects.length} objects)`);
}
/**
* Export ClickHouse database
*/
private async exportClickHouseDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>
): Promise<void> {
logger.info(`Exporting ClickHouse database: ${resource.resourceName}`);
const clickhouseService = this.oneboxRef.database.getPlatformServiceByType('clickhouse');
if (!clickhouseService || !clickhouseService.containerId) {
throw new Error('ClickHouse service not running');
}
const dbName = credentials.database || credentials.CLICKHOUSE_DB;
const user = credentials.username || credentials.CLICKHOUSE_USER || 'default';
const password = credentials.password || credentials.CLICKHOUSE_PASSWORD || '';
if (!dbName) {
throw new Error('ClickHouse database name not found in credentials');
}
// Get list of tables
const tablesResult = await this.oneboxRef.docker.execInContainer(clickhouseService.containerId, [
'clickhouse-client',
`--user=${user}`,
`--password=${password}`,
'--query',
`SELECT name FROM system.tables WHERE database = '${dbName}'`,
]);
if (tablesResult.exitCode !== 0) {
throw new Error(`Failed to list ClickHouse tables: ${tablesResult.stderr}`);
}
const tables = tablesResult.stdout.trim().split('\n').filter(Boolean);
const dumpPath = `${dataDir}/${resource.resourceName}.sql`;
let dumpContent = `-- ClickHouse backup for database: ${dbName}\n`;
dumpContent += `-- Created: ${new Date().toISOString()}\n\n`;
dumpContent += `CREATE DATABASE IF NOT EXISTS ${dbName};\n\n`;
for (const table of tables) {
// Get CREATE TABLE statement
const createResult = await this.oneboxRef.docker.execInContainer(clickhouseService.containerId, [
'clickhouse-client',
`--user=${user}`,
`--password=${password}`,
'--query',
`SHOW CREATE TABLE ${dbName}.${table}`,
]);
if (createResult.exitCode === 0) {
dumpContent += `-- Table: ${table}\n`;
dumpContent += createResult.stdout + ';\n\n';
}
// Get table data in TSV format
const dataResult = await this.oneboxRef.docker.execInContainer(clickhouseService.containerId, [
'clickhouse-client',
`--user=${user}`,
`--password=${password}`,
'--query',
`SELECT * FROM ${dbName}.${table} FORMAT TabSeparatedWithNames`,
]);
if (dataResult.exitCode === 0 && dataResult.stdout.trim()) {
// Save data to separate file for large datasets
const tableDataPath = `${dataDir}/${resource.resourceName}_${table}.tsv`;
await Deno.writeTextFile(tableDataPath, dataResult.stdout);
}
}
await Deno.writeTextFile(dumpPath, dumpContent);
logger.success(`ClickHouse database exported: ${resource.resourceName} (${tables.length} tables)`);
}
/**
* Export Docker image
*/
private async exportDockerImage(imageName: string, outputPath: string): Promise<void> {
logger.info(`Exporting Docker image: ${imageName}`);
// Use docker save command via shell
const command = new Deno.Command('docker', {
args: ['save', '-o', outputPath, imageName],
});
const result = await command.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr);
throw new Error(`docker save failed: ${stderr}`);
}
logger.success(`Docker image exported: ${imageName}`);
}
/**
* Import Docker image
*/
private async importDockerImage(imagePath: string): Promise<string> {
logger.info(`Importing Docker image from: ${imagePath}`);
// Use docker load command
const command = new Deno.Command('docker', {
args: ['load', '-i', imagePath],
});
const result = await command.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr);
throw new Error(`docker load failed: ${stderr}`);
}
const stdout = new TextDecoder().decode(result.stdout);
// Parse image name from output like "Loaded image: nginx:latest"
const match = stdout.match(/Loaded image: (.+)/);
const imageName = match ? match[1].trim() : 'unknown';
logger.success(`Docker image imported: ${imageName}`);
return imageName;
}
/**
* Restore platform resources for existing service (restore mode)
*/
private async restorePlatformResources(
serviceId: number,
backupResources: IBackupPlatformResource[],
tempDir: string,
warnings: string[]
): Promise<number> {
let restoredCount = 0;
// Get existing resources for this service
const existingResources = await this.oneboxRef.platformServices.getResourcesForService(serviceId);
for (const backupResource of backupResources) {
try {
// Find matching existing resource
const existing = existingResources.find(
(e) =>
e.platformService.type === backupResource.platformServiceType &&
e.resource.resourceType === backupResource.resourceType
);
if (!existing) {
warnings.push(
`Platform resource ${backupResource.platformServiceType}/${backupResource.resourceName} not provisioned. Skipping data import.`
);
continue;
}
// Import data based on type
const dataDir = `${tempDir}/data/${backupResource.platformServiceType}`;
switch (backupResource.platformServiceType) {
case 'mongodb':
await this.importMongoDatabase(
dataDir,
existing.resource,
existing.credentials,
backupResource.resourceName
);
restoredCount++;
break;
case 'minio':
await this.importMinioBucket(
dataDir,
existing.resource,
existing.credentials,
backupResource.resourceName
);
restoredCount++;
break;
case 'clickhouse':
await this.importClickHouseDatabase(
dataDir,
existing.resource,
existing.credentials,
backupResource.resourceName
);
restoredCount++;
break;
}
} catch (error) {
warnings.push(
`Failed to restore ${backupResource.platformServiceType} resource: ${getErrorMessage(error)}`
);
}
}
return restoredCount;
}
/**
* Restore platform data for new service (import/clone mode)
*/
private async restorePlatformData(
serviceId: number,
backupResources: IBackupPlatformResource[],
tempDir: string,
warnings: string[]
): Promise<number> {
// For new services, platform resources should have been provisioned during deployment
return this.restorePlatformResources(serviceId, backupResources, tempDir, warnings);
}
/**
* Import MongoDB database
*/
private async importMongoDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
backupResourceName: string
): Promise<void> {
logger.info(`Importing MongoDB database: ${resource.resourceName}`);
const mongoService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
if (!mongoService || !mongoService.containerId) {
throw new Error('MongoDB service not running');
}
const archivePath = `${dataDir}/${backupResourceName}.archive`;
const connectionUri = credentials.connectionUri || credentials.MONGODB_URI;
if (!connectionUri) {
throw new Error('MongoDB connection URI not found');
}
// Read local archive and copy to container
const archiveData = await Deno.readFile(archivePath);
const containerArchivePath = `/tmp/${resource.resourceName}.archive`;
// Write archive to container via exec + stdin (simplified - use cat)
// For production, use Docker API copy endpoint
const base64Data = btoa(String.fromCharCode(...archiveData));
await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'bash',
'-c',
`echo '${base64Data}' | base64 -d > ${containerArchivePath}`,
]);
// Run mongorestore
const result = await this.oneboxRef.docker.execInContainer(mongoService.containerId, [
'mongorestore',
`--uri=${connectionUri}`,
`--archive=${containerArchivePath}`,
'--gzip',
'--drop',
]);
if (result.exitCode !== 0) {
throw new Error(`mongorestore failed: ${result.stderr}`);
}
// Cleanup
await this.oneboxRef.docker.execInContainer(mongoService.containerId, ['rm', containerArchivePath]);
logger.success(`MongoDB database imported: ${resource.resourceName}`);
}
/**
* Import MinIO bucket
*/
private async importMinioBucket(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
backupResourceName: string
): Promise<void> {
logger.info(`Importing MinIO bucket: ${resource.resourceName}`);
const bucketDir = `${dataDir}/${backupResourceName}`;
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
const bucket = credentials.bucket || credentials.S3_BUCKET;
if (!endpoint || !accessKey || !secretKey || !bucket) {
throw new Error('MinIO credentials incomplete');
}
const s3Client = new plugins.smarts3.SmartS3({
endpoint,
accessKey,
secretKey,
bucket,
});
await s3Client.start();
// Walk directory and upload all files
let uploadedCount = 0;
for await (const entry of Deno.readDir(bucketDir)) {
if (entry.name === '_metadata.json') continue;
const filePath = `${bucketDir}/${entry.name}`;
if (entry.isFile) {
const fileData = await Deno.readFile(filePath);
await s3Client.putObject(entry.name, fileData);
uploadedCount++;
}
// Note: For nested directories, would need recursive handling
}
await s3Client.stop();
logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`);
}
/**
* Import ClickHouse database
*/
private async importClickHouseDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
backupResourceName: string
): Promise<void> {
logger.info(`Importing ClickHouse database: ${resource.resourceName}`);
const clickhouseService = this.oneboxRef.database.getPlatformServiceByType('clickhouse');
if (!clickhouseService || !clickhouseService.containerId) {
throw new Error('ClickHouse service not running');
}
const dbName = credentials.database || credentials.CLICKHOUSE_DB;
const user = credentials.username || credentials.CLICKHOUSE_USER || 'default';
const password = credentials.password || credentials.CLICKHOUSE_PASSWORD || '';
if (!dbName) {
throw new Error('ClickHouse database name not found');
}
// Read SQL dump
const sqlPath = `${dataDir}/${backupResourceName}.sql`;
const sqlContent = await Deno.readTextFile(sqlPath);
// Execute SQL statements
const statements = sqlContent.split(';').filter((s) => s.trim());
for (const statement of statements) {
if (statement.trim().startsWith('--')) continue;
const result = await this.oneboxRef.docker.execInContainer(clickhouseService.containerId, [
'clickhouse-client',
`--user=${user}`,
`--password=${password}`,
'--query',
statement.trim(),
]);
if (result.exitCode !== 0) {
logger.warn(`ClickHouse statement failed: ${result.stderr}`);
}
}
// Import TSV data files for each table
try {
for await (const entry of Deno.readDir(dataDir)) {
if (entry.name.endsWith('.tsv') && entry.name.startsWith(`${backupResourceName}_`)) {
const tableName = entry.name.replace(`${backupResourceName}_`, '').replace('.tsv', '');
const tsvPath = `${dataDir}/${entry.name}`;
const tsvContent = await Deno.readTextFile(tsvPath);
// Skip header line and insert data
const lines = tsvContent.split('\n');
if (lines.length > 1) {
const dataLines = lines.slice(1).join('\n');
const result = await this.oneboxRef.docker.execInContainer(clickhouseService.containerId, [
'clickhouse-client',
`--user=${user}`,
`--password=${password}`,
'--query',
`INSERT INTO ${dbName}.${tableName} FORMAT TabSeparated`,
]);
// Note: Would need to pipe data via stdin for proper import
if (result.exitCode !== 0) {
logger.warn(`ClickHouse data import failed for ${tableName}: ${result.stderr}`);
}
}
}
}
} catch {
// No TSV files found
}
logger.success(`ClickHouse database imported: ${resource.resourceName}`);
}
/**
* Create tar archive from directory
*/
private async createTarArchive(sourceDir: string, outputPath: string): Promise<void> {
const command = new Deno.Command('tar', {
args: ['-cf', outputPath, '-C', sourceDir, '.'],
});
const result = await command.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr);
throw new Error(`tar create failed: ${stderr}`);
}
}
/**
* Extract tar archive to directory
*/
private async extractTarArchive(archivePath: string, outputDir: string): Promise<void> {
const command = new Deno.Command('tar', {
args: ['-xf', archivePath, '-C', outputDir],
});
const result = await command.output();
if (!result.success) {
const stderr = new TextDecoder().decode(result.stderr);
throw new Error(`tar extract failed: ${stderr}`);
}
}
/**
* Encrypt a file using AES-256-GCM
*/
private async encryptFile(inputPath: string, outputPath: string, password: string): Promise<void> {
const data = await Deno.readFile(inputPath);
// Generate salt and derive key
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const key = await this.deriveKey(password, salt);
// Generate IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encrypt
const ciphertext = await crypto.subtle.encrypt({ name: ENCRYPTION_ALGORITHM, iv }, key, data);
// Combine: salt (32) + iv (12) + ciphertext
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
await Deno.writeFile(outputPath, combined);
}
/**
* Decrypt a file using AES-256-GCM
*/
private async decryptFile(inputPath: string, outputPath: string, password: string): Promise<void> {
const combined = await Deno.readFile(inputPath);
// Extract salt, iv, and ciphertext
const salt = combined.slice(0, SALT_LENGTH);
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH);
// Derive key
const key = await this.deriveKey(password, salt);
// Decrypt
try {
const decrypted = await crypto.subtle.decrypt({ name: ENCRYPTION_ALGORITHM, iv }, key, ciphertext);
await Deno.writeFile(outputPath, new Uint8Array(decrypted));
} catch {
throw new Error('Decryption failed. Invalid backup password or corrupted file.');
}
}
/**
* Derive encryption key from password using PBKDF2
*/
private async deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
const passwordBytes = encoder.encode(password);
const baseKey = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, ['deriveKey']);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
baseKey,
{ name: ENCRYPTION_ALGORITHM, length: KEY_LENGTH },
false,
['encrypt', 'decrypt']
);
}
/**
* Compute SHA-256 checksum
*/
private async computeChecksum(data: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
return 'sha256:' + Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join('');
}
}