PBS restore working :)
This commit is contained in:
@@ -4,7 +4,15 @@ import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server } from 'lucide-react';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
|
||||
interface Backup {
|
||||
id: number;
|
||||
@@ -15,6 +23,7 @@ interface Backup {
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_id: number;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}
|
||||
@@ -28,6 +37,11 @@ interface ContainerBackups {
|
||||
export function BackupsTab() {
|
||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
|
||||
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
|
||||
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
||||
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
|
||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||
@@ -36,6 +50,44 @@ export function BackupsTab() {
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = api.backups.restoreBackup.useMutation({
|
||||
onMutate: () => {
|
||||
// Show progress immediately when mutation starts
|
||||
setRestoreProgress(['Starting restore...']);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setRestoreSuccess(true);
|
||||
// Update progress with all messages from backend
|
||||
const progressMessages = result.progress?.map(p => p.message) || ['Restore completed successfully'];
|
||||
setRestoreProgress(progressMessages);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}, 5000);
|
||||
} else {
|
||||
setRestoreError(result.error || 'Restore failed');
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || []);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setRestoreError(error.message || 'Restore failed');
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
setRestoreProgress([]);
|
||||
},
|
||||
});
|
||||
|
||||
// Update progress text in modal based on current progress
|
||||
const currentProgressText = restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: 'Restoring backup...';
|
||||
|
||||
// Auto-discover backups when tab is first opened
|
||||
useEffect(() => {
|
||||
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
||||
@@ -51,6 +103,28 @@ export function BackupsTab() {
|
||||
discoverMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRestoreClick = (backup: Backup, containerId: string) => {
|
||||
setSelectedBackup({ backup, containerId });
|
||||
setRestoreConfirmOpen(true);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
setRestoreConfirmOpen(false);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
|
||||
restoreMutation.mutate({
|
||||
backupId: selectedBackup.backup.id,
|
||||
containerId: selectedBackup.containerId,
|
||||
serverId: selectedBackup.backup.server_id,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleContainer = (containerId: string) => {
|
||||
const newExpanded = new Set(expandedContainers);
|
||||
if (newExpanded.has(containerId)) {
|
||||
@@ -234,6 +308,34 @@ export function BackupsTab() {
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRestoreClick(backup, container.container_id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="text-muted-foreground opacity-50"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -254,6 +356,101 @@ export function BackupsTab() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Confirmation Modal */}
|
||||
{selectedBackup && (
|
||||
<ConfirmationModal
|
||||
isOpen={restoreConfirmOpen}
|
||||
onClose={() => {
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
}}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
title="Restore Backup"
|
||||
message={`This will destroy the existing container and restore from backup. The container will be stopped during restore. This action cannot be undone and may result in data loss.`}
|
||||
variant="danger"
|
||||
confirmText={selectedBackup.containerId}
|
||||
confirmButtonText="Restore"
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Progress Modal */}
|
||||
{restoreMutation.isPending && (
|
||||
<LoadingModal
|
||||
isOpen={true}
|
||||
action={currentProgressText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Progress Details - Show during restore */}
|
||||
{restoreMutation.isPending && (
|
||||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="font-medium text-foreground">Restoring backup...</span>
|
||||
</div>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Success */}
|
||||
{restoreSuccess && (
|
||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore completed successfully!</span>
|
||||
</div>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Error */}
|
||||
{restoreError && (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<span className="font-medium text-destructive">Restore failed</span>
|
||||
</div>
|
||||
<p className="text-sm text-destructive">{restoreError}</p>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRestoreError(null);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
|
||||
import { getDatabase } from '~/server/database-prisma';
|
||||
import { getBackupService } from '~/server/services/backupService';
|
||||
import { getRestoreService } from '~/server/services/restoreService';
|
||||
|
||||
export const backupsRouter = createTRPCRouter({
|
||||
// Get all backups grouped by container ID
|
||||
@@ -47,6 +48,7 @@ export const backupsRouter = createTRPCRouter({
|
||||
storage_name: backup.storage_name,
|
||||
storage_type: backup.storage_type,
|
||||
discovered_at: backup.discovered_at,
|
||||
server_id: backup.server_id,
|
||||
server_name: backup.server?.name ?? null,
|
||||
server_color: backup.server?.color ?? null,
|
||||
})),
|
||||
@@ -86,5 +88,36 @@ export const backupsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Restore backup
|
||||
restoreBackup: publicProcedure
|
||||
.input(z.object({
|
||||
backupId: z.number(),
|
||||
containerId: z.string(),
|
||||
serverId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const restoreService = getRestoreService();
|
||||
const result = await restoreService.executeRestore(
|
||||
input.backupId,
|
||||
input.containerId,
|
||||
input.serverId
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
progress: result.progress,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in restoreBackup:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore backup',
|
||||
progress: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -327,6 +327,15 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupById(id) {
|
||||
return await prisma.backup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
|
||||
@@ -364,6 +364,15 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupById(id: number) {
|
||||
return await prisma.backup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId: string) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
|
||||
568
src/server/services/restoreService.ts
Normal file
568
src/server/services/restoreService.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getBackupService } from './backupService';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
|
||||
export interface RestoreProgress {
|
||||
step: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
progress?: RestoreProgress[];
|
||||
}
|
||||
|
||||
class RestoreService {
|
||||
/**
|
||||
* Get rootfs storage from LXC config or installed scripts database
|
||||
*/
|
||||
async getRootfsStorage(server: Server, ctId: string): Promise<string | null> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const db = getDatabase();
|
||||
const configPath = `/etc/pve/lxc/${ctId}.conf`;
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null || echo ""`;
|
||||
let rawConfig = '';
|
||||
|
||||
try {
|
||||
// Try to read config file (container might not exist, so don't fail on error)
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
readCommand,
|
||||
(data: string) => {
|
||||
rawConfig += data;
|
||||
},
|
||||
() => resolve(), // Don't fail on error
|
||||
() => resolve() // Always resolve
|
||||
);
|
||||
});
|
||||
|
||||
// If we got config content, parse it
|
||||
if (rawConfig.trim()) {
|
||||
// Parse rootfs line: rootfs: PROX2-STORAGE2:vm-148-disk-0,size=4G
|
||||
const lines = rawConfig.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('rootfs:')) {
|
||||
const match = trimmed.match(/^rootfs:\s*([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If config file doesn't exist or doesn't have rootfs, try to get from installed scripts database
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
|
||||
if (script) {
|
||||
// Try to get LXC config from database
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
// Extract storage from rootfs_storage format: "STORAGE:vm-148-disk-0"
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error reading LXC config for CT ${ctId}:`, error);
|
||||
// Try fallback to database
|
||||
try {
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
if (script) {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error(`Error getting storage from database:`, dbError);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop container (continue if already stopped)
|
||||
*/
|
||||
async stopContainer(server: Server, ctId: string): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
() => {},
|
||||
() => resolve(),
|
||||
() => resolve() // Always resolve, don't fail if already stopped
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy container
|
||||
*/
|
||||
async destroyContainer(server: Server, ctId: string): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct destroy ${ctId} 2>&1`;
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Check if error is about container not existing
|
||||
if (error.includes('does not exist') || error.includes('not found')) {
|
||||
console.log(`[RestoreService] Container ${ctId} does not exist`);
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed: ${error}`));
|
||||
}
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
// Check if error is about container not existing
|
||||
if (output.includes('does not exist') || output.includes('not found') || output.includes('No such file')) {
|
||||
console.log(`[RestoreService] Container ${ctId} does not exist`);
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from local/storage backup
|
||||
*/
|
||||
async restoreLocalBackup(
|
||||
server: Server,
|
||||
ctId: string,
|
||||
backupPath: string,
|
||||
storage: string
|
||||
): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct restore ${ctId} "${backupPath}" --storage=${storage}`;
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Restore failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Restore failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from PBS backup
|
||||
*/
|
||||
async restorePBSBackup(
|
||||
server: Server,
|
||||
storage: Storage,
|
||||
ctId: string,
|
||||
snapshotPath: string,
|
||||
storageName: string,
|
||||
onProgress?: (step: string, message: string) => void
|
||||
): Promise<void> {
|
||||
const backupService = getBackupService();
|
||||
const sshService = getSSHExecutionService();
|
||||
const db = getDatabase();
|
||||
|
||||
// Get PBS credentials
|
||||
const credential = await db.getPBSCredential(server.id, storage.name);
|
||||
if (!credential) {
|
||||
throw new Error(`No PBS credentials found for storage ${storage.name}`);
|
||||
}
|
||||
|
||||
const storageService = getStorageService();
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
throw new Error(`Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
}
|
||||
|
||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||
|
||||
// Extract snapshot name from path (e.g., "2025-10-21T19:14:55Z" from "ct/148/2025-10-21T19:14:55Z")
|
||||
const snapshotParts = snapshotPath.split('/');
|
||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
||||
// Replace colons with underscores for file paths (tar doesn't like colons in filenames)
|
||||
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
|
||||
|
||||
// Determine file extension - try common extensions
|
||||
const extensions = ['.tar', '.tar.zst', '.pxar'];
|
||||
let downloadedPath = '';
|
||||
let downloadSuccess = false;
|
||||
|
||||
// Login to PBS first
|
||||
if (onProgress) onProgress('pbs_login', 'Logging into PBS...');
|
||||
console.log(`[RestoreService] Logging into PBS for storage ${storage.name}`);
|
||||
const loggedIn = await backupService.loginToPBS(server, storage);
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to login to PBS for storage ${storage.name}`);
|
||||
}
|
||||
console.log(`[RestoreService] PBS login successful`);
|
||||
|
||||
// Download backup from PBS
|
||||
// proxmox-backup-client restore outputs a folder, not a file
|
||||
if (onProgress) onProgress('pbs_download', 'Downloading backup from PBS...');
|
||||
console.log(`[RestoreService] Starting download of snapshot ${snapshotPath}`);
|
||||
|
||||
// Target folder for PBS restore (without extension)
|
||||
// Use sanitized snapshot name (colons replaced with underscores) for file paths
|
||||
const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
|
||||
const targetTar = `${targetFolder}.tar`;
|
||||
|
||||
// Use PBS_PASSWORD env var and add timeout for long downloads
|
||||
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
||||
const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
|
||||
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
// Download from PBS (creates a folder)
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
restoreCommand,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
console.log(`[RestoreService] Download output: ${data}`);
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`[RestoreService] Download error: ${error}`);
|
||||
reject(new Error(`Download failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
console.log(`[RestoreService] Download command exited with code ${exitCode}`);
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`[RestoreService] Download failed: ${output}`);
|
||||
reject(new Error(`Download failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Download timeout after 5 minutes'));
|
||||
}, 300000); // 5 minute timeout
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if folder exists
|
||||
const checkCommand = `test -d "${targetFolder}" && echo "exists" || echo "notfound"`;
|
||||
let checkOutput = '';
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
checkOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[RestoreService] Folder check result: ${checkOutput}`);
|
||||
|
||||
if (!checkOutput.includes('exists')) {
|
||||
throw new Error(`Downloaded folder ${targetFolder} does not exist`);
|
||||
}
|
||||
|
||||
// Pack the folder into a tar file
|
||||
if (onProgress) onProgress('pbs_pack', 'Packing backup folder...');
|
||||
console.log(`[RestoreService] Packing folder ${targetFolder} into ${targetTar}`);
|
||||
|
||||
// Use -C to change to the folder directory, then pack all contents (.) into the tar file
|
||||
const packCommand = `tar -cf "${targetTar}" -C "${targetFolder}" . 2>&1`;
|
||||
let packOutput = '';
|
||||
let packExitCode = 0;
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
packCommand,
|
||||
(data: string) => {
|
||||
packOutput += data;
|
||||
console.log(`[RestoreService] Pack output: ${data}`);
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`[RestoreService] Pack error: ${error}`);
|
||||
reject(new Error(`Pack failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
packExitCode = code;
|
||||
console.log(`[RestoreService] Pack command exited with code ${packExitCode}`);
|
||||
if (packExitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`[RestoreService] Pack failed: ${packOutput}`);
|
||||
reject(new Error(`Pack failed with exit code ${packExitCode}: ${packOutput}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Pack timeout after 2 minutes'));
|
||||
}, 120000); // 2 minute timeout for packing
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if tar file exists
|
||||
const checkTarCommand = `test -f "${targetTar}" && echo "exists" || echo "notfound"`;
|
||||
let checkTarOutput = '';
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkTarCommand,
|
||||
(data: string) => {
|
||||
checkTarOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[RestoreService] Tar file check result: ${checkTarOutput}`);
|
||||
|
||||
if (!checkTarOutput.includes('exists')) {
|
||||
throw new Error(`Packed tar file ${targetTar} does not exist`);
|
||||
}
|
||||
|
||||
downloadedPath = targetTar;
|
||||
downloadSuccess = true;
|
||||
console.log(`[RestoreService] Successfully downloaded and packed backup to ${targetTar}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[RestoreService] Failed to download/pack backup:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!downloadSuccess || !downloadedPath) {
|
||||
throw new Error(`Failed to download and pack backup from PBS`);
|
||||
}
|
||||
|
||||
// Restore from packed tar file
|
||||
if (onProgress) onProgress('restoring', 'Restoring container...');
|
||||
try {
|
||||
console.log(`[RestoreService] Starting Restore from ${targetTar}`);
|
||||
await this.restoreLocalBackup(server, ctId, downloadedPath, storageName);
|
||||
} finally {
|
||||
// Cleanup: delete downloaded folder and tar file
|
||||
if (onProgress) onProgress('cleanup', 'Cleaning up temporary files...');
|
||||
const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
cleanupCommand,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute full restore flow
|
||||
*/
|
||||
async executeRestore(
|
||||
backupId: number,
|
||||
containerId: string,
|
||||
serverId: number,
|
||||
onProgress?: (progress: RestoreProgress) => void
|
||||
): Promise<RestoreResult> {
|
||||
const progress: RestoreProgress[] = [];
|
||||
|
||||
const addProgress = (step: string, message: string) => {
|
||||
const p = { step, message };
|
||||
progress.push(p);
|
||||
if (onProgress) {
|
||||
onProgress(p);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
console.log(`[RestoreService] Starting restore for backup ${backupId}, CT ${containerId}, server ${serverId}`);
|
||||
|
||||
// Get backup details
|
||||
const backup = await db.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
throw new Error(`Backup with ID ${backupId} not found`);
|
||||
}
|
||||
|
||||
console.log(`[RestoreService] Backup found: ${backup.backup_name}, type: ${backup.storage_type}, path: ${backup.backup_path}`);
|
||||
|
||||
// Get server details
|
||||
const server = await db.getServerById(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server with ID ${serverId} not found`);
|
||||
}
|
||||
|
||||
// Get rootfs storage
|
||||
addProgress('reading_config', 'Reading container configuration...');
|
||||
console.log(`[RestoreService] Getting rootfs storage for CT ${containerId}`);
|
||||
const rootfsStorage = await this.getRootfsStorage(server, containerId);
|
||||
console.log(`[RestoreService] Rootfs storage: ${rootfsStorage || 'not found'}`);
|
||||
|
||||
if (!rootfsStorage) {
|
||||
// Try to check if container exists, if not we can proceed without stopping/destroying
|
||||
const checkCommand = `pct list ${containerId} 2>&1 | grep -q "^${containerId}" && echo "exists" || echo "notfound"`;
|
||||
let checkOutput = '';
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
checkOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
if (checkOutput.includes('notfound')) {
|
||||
// Container doesn't exist, we can't determine storage - need user input or use default
|
||||
throw new Error(`Container ${containerId} does not exist and storage could not be determined. Please ensure the container exists or specify the storage manually.`);
|
||||
}
|
||||
|
||||
throw new Error(`Could not determine rootfs storage for container ${containerId}. Please ensure the container exists and has a valid configuration.`);
|
||||
}
|
||||
|
||||
// Try to stop and destroy container - if it doesn't exist, continue anyway
|
||||
addProgress('stopping', 'Stopping container...');
|
||||
try {
|
||||
await this.stopContainer(server, containerId);
|
||||
console.log(`[RestoreService] Container ${containerId} stopped`);
|
||||
} catch (error) {
|
||||
console.warn(`[RestoreService] Failed to stop container (may not exist or already stopped):`, error);
|
||||
// Continue even if stop fails
|
||||
}
|
||||
|
||||
// Try to destroy container - if it doesn't exist, continue anyway
|
||||
addProgress('destroying', 'Destroying container...');
|
||||
try {
|
||||
await this.destroyContainer(server, containerId);
|
||||
console.log(`[RestoreService] Container ${containerId} destroyed successfully`);
|
||||
} catch (error) {
|
||||
// Container might not exist, which is fine - continue with restore
|
||||
console.log(`[RestoreService] Container ${containerId} does not exist or destroy failed (continuing anyway):`, error);
|
||||
addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||
}
|
||||
|
||||
// Restore based on backup type
|
||||
if (backup.storage_type === 'pbs') {
|
||||
console.log(`[RestoreService] Restoring from PBS backup`);
|
||||
// Get storage info for PBS
|
||||
const storageService = getStorageService();
|
||||
const storages = await storageService.getStorages(server, false);
|
||||
const storage = storages.find(s => s.name === backup.storage_name);
|
||||
|
||||
if (!storage) {
|
||||
throw new Error(`Storage ${backup.storage_name} not found`);
|
||||
}
|
||||
|
||||
// Parse snapshot path from backup_path (format: pbs://root@pam@IP:DATASTORE/ct/148/2025-10-21T19:14:55Z)
|
||||
const snapshotPathMatch = backup.backup_path.match(/pbs:\/\/[^/]+\/(.+)$/);
|
||||
if (!snapshotPathMatch || !snapshotPathMatch[1]) {
|
||||
throw new Error(`Invalid PBS backup path format: ${backup.backup_path}`);
|
||||
}
|
||||
|
||||
const snapshotPath = snapshotPathMatch[1];
|
||||
console.log(`[RestoreService] Snapshot path: ${snapshotPath}, Storage: ${rootfsStorage}`);
|
||||
|
||||
await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, (step, message) => {
|
||||
addProgress(step, message);
|
||||
});
|
||||
} else {
|
||||
// Local or storage backup
|
||||
console.log(`[RestoreService] Restoring from ${backup.storage_type} backup: ${backup.backup_path}`);
|
||||
addProgress('restoring', 'Restoring container...');
|
||||
await this.restoreLocalBackup(server, containerId, backup.backup_path, rootfsStorage);
|
||||
console.log(`[RestoreService] Local restore completed`);
|
||||
}
|
||||
|
||||
addProgress('complete', 'Restore completed successfully');
|
||||
|
||||
console.log(`[RestoreService] Restore completed successfully for CT ${containerId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
progress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
console.error(`[RestoreService] Restore failed for CT ${containerId}:`, error);
|
||||
addProgress('error', `Error: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let restoreServiceInstance: RestoreService | null = null;
|
||||
|
||||
export function getRestoreService(): RestoreService {
|
||||
if (!restoreServiceInstance) {
|
||||
restoreServiceInstance = new RestoreService();
|
||||
}
|
||||
return restoreServiceInstance;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user