From 33a5b8e4d0f0d848aa0aa5817f3d6e23f983a006 Mon Sep 17 00:00:00 2001
From: Michel Roegl-Brunner
Date: Fri, 14 Nov 2025 15:19:34 +0100
Subject: [PATCH] PBS restore working :)
---
src/app/_components/BackupsTab.tsx | 199 ++++++++-
src/server/api/routers/backups.ts | 33 ++
src/server/database-prisma.js | 9 +
src/server/database-prisma.ts | 9 +
src/server/services/restoreService.ts | 568 ++++++++++++++++++++++++++
5 files changed, 817 insertions(+), 1 deletion(-)
create mode 100644 src/server/services/restoreService.ts
diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx
index 65e4972..daaee40 100644
--- a/src/app/_components/BackupsTab.tsx
+++ b/src/app/_components/BackupsTab.tsx
@@ -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>(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([]);
+ const [restoreSuccess, setRestoreSuccess] = useState(false);
+ const [restoreError, setRestoreError] = useState(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() {
+
+
+
+
+
+
+ handleRestoreClick(backup, container.container_id)}
+ disabled={restoreMutation.isPending}
+ className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
+ >
+ Restore
+
+
+ Delete
+
+
+
+
))}
@@ -254,6 +356,101 @@ export function BackupsTab() {
)}
+
+ {/* Restore Confirmation Modal */}
+ {selectedBackup && (
+ {
+ 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 && (
+
+ )}
+
+ {/* Restore Progress Details - Show during restore */}
+ {restoreMutation.isPending && (
+
+
+
+ Restoring backup...
+
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Restore Success */}
+ {restoreSuccess && (
+
+
+
+ Restore completed successfully!
+
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Restore Error */}
+ {restoreError && (
+
+
+
{restoreError}
+ {restoreProgress.length > 0 && (
+
+ {restoreProgress.map((message, index) => (
+
+ {message}
+
+ ))}
+
+ )}
+
+
+ )}
);
}
diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts
index 78f0f09..7517a2c 100644
--- a/src/server/api/routers/backups.ts
+++ b/src/server/api/routers/backups.ts
@@ -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: [],
+ };
+ }
+ }),
});
diff --git a/src/server/database-prisma.js b/src/server/database-prisma.js
index 951765f..51f6461 100644
--- a/src/server/database-prisma.js
+++ b/src/server/database-prisma.js
@@ -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 },
diff --git a/src/server/database-prisma.ts b/src/server/database-prisma.ts
index 471f623..37d9cca 100644
--- a/src/server/database-prisma.ts
+++ b/src/server/database-prisma.ts
@@ -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 },
diff --git a/src/server/services/restoreService.ts b/src/server/services/restoreService.ts
new file mode 100644
index 0000000..a844b84
--- /dev/null
+++ b/src/server/services/restoreService.ts
@@ -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 {
+ 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((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 {
+ const sshService = getSSHExecutionService();
+ const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped
+
+ await new Promise((resolve) => {
+ sshService.executeCommand(
+ server,
+ command,
+ () => {},
+ () => resolve(),
+ () => resolve() // Always resolve, don't fail if already stopped
+ );
+ });
+ }
+
+ /**
+ * Destroy container
+ */
+ async destroyContainer(server: Server, ctId: string): Promise {
+ const sshService = getSSHExecutionService();
+ const command = `pct destroy ${ctId} 2>&1`;
+ let output = '';
+ let exitCode = 0;
+
+ await new Promise((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 {
+ const sshService = getSSHExecutionService();
+ const command = `pct restore ${ctId} "${backupPath}" --storage=${storage}`;
+ let output = '';
+ let exitCode = 0;
+
+ await new Promise((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 {
+ 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((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((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((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((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((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((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 {
+ 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((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;
+}
+